labnotes 0.0.77__tar.gz → 0.0.79__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.77 → labnotes-0.0.79}/PKG-INFO +1 -1
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/context.md +4 -1
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/create_app.py +30 -0
- labnotes-0.0.79/src/labnotes/daemon/control_run.py +16 -0
- labnotes-0.0.79/src/labnotes/load_run.py +21 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/run_command.py +2 -0
- labnotes-0.0.79/src/labnotes/run_control.py +46 -0
- labnotes-0.0.79/src/labnotes/run_output.py +66 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/labnotes.css +13 -0
- labnotes-0.0.79/src/labnotes/static/row-link.js +8 -0
- labnotes-0.0.79/src/labnotes/static/run-output.js +24 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/open_tables.py +14 -0
- labnotes-0.0.79/src/labnotes/tables/read_log.py +20 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/experiment.html +2 -1
- labnotes-0.0.79/src/labnotes/templates/run.html +19 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/testing-conventions.toml +6 -0
- labnotes-0.0.77/src/labnotes/static/row-link.js +0 -9
- {labnotes-0.0.77 → labnotes-0.0.79}/.gitignore +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/CHANGELOG.md +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/MIGRATIONS.md +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/README.md +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/pyproject.toml +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/__main__.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/cli.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/count_groups.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/create_note.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/__init__.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/daemon_app.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/daemon_client.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/daemon_running.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/daemon_server.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/describe_status.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/ensure_daemon.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/exit_when_idle.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/remote_start.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/run_daemon.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/run_logs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/serve_daemon.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/socket_path.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/stop_runs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/watch_daemon.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/watch_status.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon_command.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/experiments.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/file_changes.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/format_started.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/group_runs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/init.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/invocation_started.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/load_config.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/load_experiment.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/models.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/modes.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/nests_runs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/note_path.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/read_config.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/read_path.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/run_status.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/__init__.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/describe_event.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/execute_run.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/hold_folder.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/new_param_run_id.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/param_run_name.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/prepare_experiment.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/read_experiment_config.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/run_experiment.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/signal_tree.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/wait_for_tree.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/save_conflict_copy.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/save_note.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/server.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/shared_params.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/experiment.js +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/local-time.js +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/new-note.js +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/note-edit.js +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/note-editor.js +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/__init__.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/experiment_rows.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/experiment_run_ids.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/file_text.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/note_text.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/editor_imports.html +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/note.html +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/run_app.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/wait_for_note.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/tests/integration/conftest.py +0 -0
- {labnotes-0.0.77 → labnotes-0.0.79}/uv.lock +0 -0
|
@@ -126,7 +126,10 @@ going. Elsewhere, it prints a line per event instead.
|
|
|
126
126
|
far plus the latest line each running run logged. `-f` keeps following, and
|
|
127
127
|
waits for a daemon when none is up. `--json` prints one JSON object per line.
|
|
128
128
|
Following doesn't keep the daemon up.
|
|
129
|
+
- `labnotes run control NAME ACTION [ARG]` sends one live-view control to a
|
|
130
|
+
running experiment: `kill`, `retry`, `run_next` or `toggle` a run,
|
|
131
|
+
`set_concurrency` to a number, `interrupt` or `finish`.
|
|
129
132
|
- A second `run` of the same experiment fails while the first is going.
|
|
130
133
|
- It exits 0 even if runs fail; the summary line counts failures.
|
|
131
134
|
|
|
132
|
-
`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.
|
|
135
|
+
`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. Clicking a run opens its `stdout.log` and `stderr.log` together, followed as they're written until the run ends.
|
|
@@ -28,11 +28,13 @@ from labnotes.format_started import format_started
|
|
|
28
28
|
from labnotes.format_value import format_value
|
|
29
29
|
from labnotes.list_experiments import list_experiments
|
|
30
30
|
from labnotes.load_experiment import load_experiment
|
|
31
|
+
from labnotes.load_run import load_run
|
|
31
32
|
from labnotes.models import LabnotesConfig
|
|
32
33
|
from labnotes.note_path import note_path
|
|
33
34
|
from labnotes.notebook_kernel import NotebookKernel
|
|
34
35
|
from labnotes.python_blocks import python_blocks
|
|
35
36
|
from labnotes.render_markdown import render_markdown
|
|
37
|
+
from labnotes.run_output import run_output
|
|
36
38
|
from labnotes.save_conflict_copy import save_conflict_copy
|
|
37
39
|
from labnotes.save_note import save_note
|
|
38
40
|
from labnotes.saved_outputs import saved_outputs
|
|
@@ -84,6 +86,32 @@ def create_app(config: LabnotesConfig) -> Starlette:
|
|
|
84
86
|
request, "experiment.html", {"experiment": loaded}
|
|
85
87
|
)
|
|
86
88
|
|
|
89
|
+
async def find_run(request: Request) -> dict[str, object]:
|
|
90
|
+
found = await load_run(
|
|
91
|
+
request.app.state.tables,
|
|
92
|
+
request.path_params["name"],
|
|
93
|
+
request.path_params["run"],
|
|
94
|
+
)
|
|
95
|
+
if found is None:
|
|
96
|
+
raise HTTPException(404, "No such run")
|
|
97
|
+
return found
|
|
98
|
+
|
|
99
|
+
async def run(request: Request) -> Response:
|
|
100
|
+
found = await find_run(request)
|
|
101
|
+
return templates.TemplateResponse(request, "run.html", {"run": found})
|
|
102
|
+
|
|
103
|
+
async def output(request: Request) -> Response:
|
|
104
|
+
await find_run(request)
|
|
105
|
+
return StreamingResponse(
|
|
106
|
+
run_output(
|
|
107
|
+
request.app.state.tables,
|
|
108
|
+
config.root,
|
|
109
|
+
request.path_params["name"],
|
|
110
|
+
request.path_params["run"],
|
|
111
|
+
),
|
|
112
|
+
media_type="text/event-stream",
|
|
113
|
+
)
|
|
114
|
+
|
|
87
115
|
async def read_note(request: Request) -> dict[str, str]:
|
|
88
116
|
name = request.path_params["name"]
|
|
89
117
|
note = request.path_params["note"]
|
|
@@ -191,6 +219,8 @@ def create_app(config: LabnotesConfig) -> Starlette:
|
|
|
191
219
|
Route("/", index),
|
|
192
220
|
Route("/experiments/{name}", experiment),
|
|
193
221
|
Route("/experiments/{name}/changes", changes),
|
|
222
|
+
Route("/experiments/{name}/runs/{run:path}", run),
|
|
223
|
+
Route("/experiments/{name}/output/{run:path}", output),
|
|
194
224
|
Route("/experiments/{name}/notes", post_note, methods=["POST"]),
|
|
195
225
|
Route("/experiments/{name}/notes/{note:path}", get_note),
|
|
196
226
|
Route("/experiments/{name}/notes/{note:path}", put_note, methods=["PUT"]),
|
|
@@ -0,0 +1,16 @@
|
|
|
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 control_run(
|
|
8
|
+
root: Path, name: str, action: str, args: list[object]
|
|
9
|
+
) -> str | None:
|
|
10
|
+
if not await daemon_running(root):
|
|
11
|
+
return "Nothing is running."
|
|
12
|
+
async with daemon_client(root) as client:
|
|
13
|
+
reply = await client.post(f"/experiments/{name}/{action}", json={"args": args})
|
|
14
|
+
if reply.is_success:
|
|
15
|
+
return None
|
|
16
|
+
return str(reply.json()["error"])
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from dirsql import DirSQL
|
|
4
|
+
|
|
5
|
+
from labnotes.sql_quote import quote_string
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def load_run(
|
|
9
|
+
tables: DirSQL, experiment: str, run: str
|
|
10
|
+
) -> dict[str, object] | None:
|
|
11
|
+
rows = await tables.query(
|
|
12
|
+
"SELECT runs.experiment, experiments.display_name, runs.id, runs.folder,"
|
|
13
|
+
" runs.created, runs.ended, runs.exit_code, runs.params"
|
|
14
|
+
" FROM runs JOIN experiments ON experiments.name = runs.experiment"
|
|
15
|
+
f" WHERE runs.experiment = {quote_string(experiment)}"
|
|
16
|
+
f" AND runs.id = {quote_string(run)}"
|
|
17
|
+
)
|
|
18
|
+
if not rows:
|
|
19
|
+
return None
|
|
20
|
+
[row] = rows
|
|
21
|
+
return {**row, "params": json.loads(str(row["params"]))}
|
|
@@ -6,6 +6,7 @@ from labnotes.daemon.stop_runs import stop_runs
|
|
|
6
6
|
from labnotes.load_config import load_config
|
|
7
7
|
from labnotes.read_config import CONFIG_FILE
|
|
8
8
|
from labnotes.run_status import run_status
|
|
9
|
+
from labnotes.run_control import run_control
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@click.group(name="run", help="Control the runs going in this project.")
|
|
@@ -28,3 +29,4 @@ def stop(ctx: click.Context) -> None:
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
run_command.add_command(run_status)
|
|
32
|
+
run_command.add_command(run_control)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from labnotes.daemon.control_run import control_run
|
|
7
|
+
from labnotes.load_config import load_config
|
|
8
|
+
from labnotes.models import Controls
|
|
9
|
+
from labnotes.read_config import CONFIG_FILE
|
|
10
|
+
|
|
11
|
+
PARAMS = {
|
|
12
|
+
action: typing.get_args(hint)[0]
|
|
13
|
+
for action, hint in typing.get_type_hints(Controls).items()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command(
|
|
18
|
+
name="control",
|
|
19
|
+
help=(
|
|
20
|
+
"Send one control to an experiment's runs, as the live view's keys do. "
|
|
21
|
+
"kill, retry, run_next and toggle take a run; set_concurrency a number; "
|
|
22
|
+
"interrupt and finish nothing."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
@click.argument("name")
|
|
26
|
+
@click.argument("action", type=click.Choice(list(PARAMS)))
|
|
27
|
+
@click.argument("args", nargs=-1)
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def run_control(
|
|
30
|
+
ctx: click.Context, name: str, action: str, args: tuple[str, ...]
|
|
31
|
+
) -> None:
|
|
32
|
+
config = load_config(ctx)
|
|
33
|
+
if config is None:
|
|
34
|
+
raise click.ClickException(
|
|
35
|
+
f"No {CONFIG_FILE} here. Run labnotes init, or pass -c PATH."
|
|
36
|
+
)
|
|
37
|
+
kinds = PARAMS[action]
|
|
38
|
+
if len(args) != len(kinds):
|
|
39
|
+
raise click.UsageError(f"{action} takes {len(kinds)} argument(s).")
|
|
40
|
+
try:
|
|
41
|
+
values = [kind(args[i]) for i, kind in enumerate(kinds)]
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise click.UsageError(f"{action} takes a number.") from None
|
|
44
|
+
refused = asyncio.run(control_run(config.root, name, action, values))
|
|
45
|
+
if refused is not None:
|
|
46
|
+
raise click.ClickException(refused)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import codecs
|
|
2
|
+
import json
|
|
3
|
+
from collections.abc import AsyncIterator, Iterable, Iterator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dirsql import DirSQL
|
|
7
|
+
|
|
8
|
+
from labnotes.load_run import load_run
|
|
9
|
+
from labnotes.tables.read_log import STREAMS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def run_output(
|
|
13
|
+
tables: DirSQL, root: Path, experiment: str, run_id: str
|
|
14
|
+
) -> AsyncIterator[str]:
|
|
15
|
+
# Watch before reading, so nothing written in between is missed.
|
|
16
|
+
events = tables.watch()
|
|
17
|
+
run = await load_run(tables, experiment, run_id)
|
|
18
|
+
if run is None:
|
|
19
|
+
return
|
|
20
|
+
folder = root / str(run["folder"])
|
|
21
|
+
offsets = dict.fromkeys(STREAMS, 0)
|
|
22
|
+
decoders = {
|
|
23
|
+
stream: codecs.getincrementaldecoder("utf-8")("replace") for stream in STREAMS
|
|
24
|
+
}
|
|
25
|
+
last: str | None = None
|
|
26
|
+
mid_line = False
|
|
27
|
+
|
|
28
|
+
def read(streams: Iterable[str]) -> Iterator[str]:
|
|
29
|
+
nonlocal last, mid_line
|
|
30
|
+
for stream in streams:
|
|
31
|
+
path = folder / f"{stream}.log"
|
|
32
|
+
if not path.exists():
|
|
33
|
+
continue
|
|
34
|
+
with path.open("rb") as log:
|
|
35
|
+
log.seek(offsets[stream])
|
|
36
|
+
data = log.read()
|
|
37
|
+
offsets[stream] += len(data)
|
|
38
|
+
text = decoders[stream].decode(data)
|
|
39
|
+
if not text:
|
|
40
|
+
continue
|
|
41
|
+
if mid_line and stream != last:
|
|
42
|
+
text = "\n" + text
|
|
43
|
+
last, mid_line = stream, not text.endswith("\n")
|
|
44
|
+
yield f"data: {json.dumps({'stream': stream, 'text': text})}\n\n"
|
|
45
|
+
|
|
46
|
+
for message in read(STREAMS):
|
|
47
|
+
yield message
|
|
48
|
+
exit_code = run["exit_code"]
|
|
49
|
+
if run["ended"] is None:
|
|
50
|
+
async for event in events:
|
|
51
|
+
row = event.row
|
|
52
|
+
if row is None or row["experiment"] != experiment:
|
|
53
|
+
continue
|
|
54
|
+
if event.table == "logs" and row["run"] == run_id:
|
|
55
|
+
for message in read([row["stream"]]):
|
|
56
|
+
yield message
|
|
57
|
+
elif (
|
|
58
|
+
event.table == "runs"
|
|
59
|
+
and row["id"] == run_id
|
|
60
|
+
and row["ended"] is not None
|
|
61
|
+
):
|
|
62
|
+
exit_code = row["exit_code"]
|
|
63
|
+
break
|
|
64
|
+
for message in read(STREAMS):
|
|
65
|
+
yield message
|
|
66
|
+
yield f"event: end\ndata: {json.dumps(exit_code)}\n\n"
|
|
@@ -295,6 +295,19 @@ h3 {
|
|
|
295
295
|
.group-toggle[aria-expanded="true"]::before { content: "▾"; }
|
|
296
296
|
.group:not(.open) > :not(.group-row) { display: none; }
|
|
297
297
|
.group.open .run-row td:nth-child(2) { padding-left: 2rem; }
|
|
298
|
+
.run-row { cursor: pointer; }
|
|
299
|
+
.run-link { text-decoration: none; }
|
|
300
|
+
|
|
301
|
+
/* Run output */
|
|
302
|
+
.run-state.good { color: var(--good); }
|
|
303
|
+
.run-state.bad { color: var(--bad); }
|
|
304
|
+
.run-log { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; }
|
|
305
|
+
.run-log header { display: flex; gap: 1rem; padding: 0.4rem 1.25rem; border-bottom: 1px solid var(--rule); font-family: var(--mono); font-size: 0.75rem; color: var(--ink-3); }
|
|
306
|
+
.run-log header span::before { content: "■ "; }
|
|
307
|
+
.run-output { margin: 0; padding: 1rem 1.25rem; max-height: 70vh; overflow: auto; font-size: 0.82rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
308
|
+
.run-output:empty::before { content: "No output yet."; color: var(--ink-3); }
|
|
309
|
+
.run-output .stderr, .run-log header .stderr::before { color: var(--bad); }
|
|
310
|
+
.run-log header .stdout::before { color: var(--ink-2); }
|
|
298
311
|
|
|
299
312
|
/* Notes */
|
|
300
313
|
.note {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Safari ignores position: relative on <tr>, so a stretched ::after link covers the whole page.
|
|
2
|
+
// Delegated, so rows the runs filter swaps in stay clickable.
|
|
3
|
+
document.addEventListener("click", (event) => {
|
|
4
|
+
const link = event.target.closest(".ledger tbody tr, .run-row")?.querySelector(".title-link, .run-link");
|
|
5
|
+
if (!link || event.target.closest("a") || getSelection().toString()) return;
|
|
6
|
+
if (event.metaKey || event.ctrlKey) window.open(link.href, "_blank");
|
|
7
|
+
else location.href = link.href;
|
|
8
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const output = document.querySelector(".run-output");
|
|
2
|
+
const state = document.querySelector(".run-state");
|
|
3
|
+
const source = new EventSource(output.dataset.url);
|
|
4
|
+
|
|
5
|
+
// The server resends the whole log on each connection, so a reconnect starts clean.
|
|
6
|
+
source.addEventListener("open", () => output.replaceChildren());
|
|
7
|
+
|
|
8
|
+
source.addEventListener("message", (event) => {
|
|
9
|
+
const { stream, text } = JSON.parse(event.data);
|
|
10
|
+
const following = output.scrollTop + output.clientHeight >= output.scrollHeight - 4;
|
|
11
|
+
const chunk = document.createElement("span");
|
|
12
|
+
chunk.className = stream;
|
|
13
|
+
chunk.textContent = text;
|
|
14
|
+
output.append(chunk);
|
|
15
|
+
if (following) output.scrollTop = output.scrollHeight;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
source.addEventListener("end", (event) => {
|
|
19
|
+
source.close();
|
|
20
|
+
const code = JSON.parse(event.data);
|
|
21
|
+
state.textContent = code === null ? "ended" : `exit ${code}`;
|
|
22
|
+
state.classList.add(code === 0 ? "good" : "bad");
|
|
23
|
+
output.dataset.ended = "";
|
|
24
|
+
});
|
|
@@ -6,6 +6,7 @@ from labnotes.models import LabnotesConfig
|
|
|
6
6
|
from labnotes.tables.path_glob import path_glob
|
|
7
7
|
from labnotes.tables.read_experiment import read_experiment
|
|
8
8
|
from labnotes.tables.read_file import read_file
|
|
9
|
+
from labnotes.tables.read_log import read_log
|
|
9
10
|
from labnotes.tables.read_run import read_run
|
|
10
11
|
|
|
11
12
|
# experiments is a view, so files and runs can't declare a foreign key to it.
|
|
@@ -40,6 +41,13 @@ CREATE TABLE runs (
|
|
|
40
41
|
PRIMARY KEY (experiment, id)
|
|
41
42
|
)
|
|
42
43
|
"""
|
|
44
|
+
# Sizes only: the server reads log text from disk, the row change is the signal.
|
|
45
|
+
LOGS = """
|
|
46
|
+
CREATE TABLE logs (
|
|
47
|
+
experiment TEXT, run TEXT, stream TEXT, size INTEGER,
|
|
48
|
+
PRIMARY KEY (experiment, run, stream)
|
|
49
|
+
)
|
|
50
|
+
"""
|
|
43
51
|
|
|
44
52
|
|
|
45
53
|
def open_tables(config: LabnotesConfig) -> DirSQL:
|
|
@@ -59,6 +67,12 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
|
|
|
59
67
|
glob=path_glob(f"{config.runs}/run.json"),
|
|
60
68
|
on_file=partial(read_run, root, config.runs),
|
|
61
69
|
),
|
|
70
|
+
Table(
|
|
71
|
+
name="logs",
|
|
72
|
+
ddl=LOGS,
|
|
73
|
+
glob=path_glob(f"{config.runs}/std*.log"),
|
|
74
|
+
on_file=partial(read_log, root, config.runs),
|
|
75
|
+
),
|
|
62
76
|
Table(
|
|
63
77
|
name="experiment_configs",
|
|
64
78
|
ddl=EXPERIMENTS,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from labnotes.tables.path_pattern import path_pattern
|
|
4
|
+
|
|
5
|
+
STREAMS = ("stdout", "stderr")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_log(root: Path, template: str, path: str) -> list[dict[str, object]]:
|
|
9
|
+
relative = Path(path).relative_to(root).as_posix()
|
|
10
|
+
found = path_pattern(f"{template}/{{stream}}.log").fullmatch(relative)
|
|
11
|
+
if found is None or found["stream"] not in STREAMS:
|
|
12
|
+
return []
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
"experiment": found["experiment"],
|
|
16
|
+
"run": found["run"],
|
|
17
|
+
"stream": found["stream"],
|
|
18
|
+
"size": Path(path).stat().st_size,
|
|
19
|
+
}
|
|
20
|
+
]
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
{% macro run_rows(run) %}
|
|
84
84
|
<tr class="run-row">
|
|
85
85
|
<td><span class="status {{ 'good' if run.labnotes_completed else 'bad' }}" title="{{ 'complete' if run.labnotes_completed else 'not complete' }}">{{ '✓' if run.labnotes_completed else '✕' }}</span></td>
|
|
86
|
-
{% for column in e.run_columns %}{% if column == 'id'
|
|
86
|
+
{% for column in e.run_columns %}{% if column == 'id' %}<td class="mono"><a class="run-link" href="{{ url_for('run', name=e.name, run=run.id) }}">{{ run.id.partition('/')[2] or run.id if nested else run.id }}</a></td>{% if nested %}<td></td>{% endif %}{% else %}<td class="{{ 'mono' if loop.first }}{{ 'num' if column == 'duration' }}">{{ run[column] | duration if column == 'duration' else (run[column] | started if column == 'started' else run[column] | value) }}</td>{% endif %}{% endfor %}
|
|
87
87
|
</tr>
|
|
88
88
|
{% endmacro %}
|
|
89
89
|
{% if nested %}
|
|
@@ -117,4 +117,5 @@
|
|
|
117
117
|
{% endif %}
|
|
118
118
|
<script src="{{ asset('experiment.js') }}" defer></script>
|
|
119
119
|
<script src="{{ asset('local-time.js') }}" defer></script>
|
|
120
|
+
<script src="{{ asset('row-link.js') }}" defer></script>
|
|
120
121
|
{% endblock %}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}{{ run.id }} · {{ run.display_name }} · labnotes{% endblock %}
|
|
3
|
+
{% block crumbs %}<span class="crumb"><a href="{{ url_for('index') }}">Experiments</a></span><span class="crumb"><a href="{{ url_for('experiment', name=run.experiment) }}">{{ run.display_name }}</a></span><span class="crumb mono">{{ run.id }}</span>{% endblock %}
|
|
4
|
+
{% block main %}
|
|
5
|
+
<section class="intro">
|
|
6
|
+
<h1 class="mono">{{ run.id }}</h1>
|
|
7
|
+
<p class="config-summary">
|
|
8
|
+
{% if run.ended is none %}<span class="chip run-state">running</span>{% elif run.exit_code is none %}<span class="chip run-state bad">ended</span>{% else %}<span class="chip run-state {{ 'good' if run.exit_code == 0 else 'bad' }}">exit {{ run.exit_code }}</span>{% endif %}
|
|
9
|
+
{% if run.created %}<span class="setting">started <code>{{ run.created }}</code></span>{% endif %}
|
|
10
|
+
{% for key, value in run.params.items() %}<span class="setting">{{ key }} <code>{{ value | value }}</code></span>{% endfor %}
|
|
11
|
+
</p>
|
|
12
|
+
</section>
|
|
13
|
+
|
|
14
|
+
<section class="run-log">
|
|
15
|
+
<header><span class="stdout">stdout</span><span class="stderr">stderr</span></header>
|
|
16
|
+
<pre class="run-output" data-url="{{ url_for('output', name=run.experiment, run=run.id) }}"></pre>
|
|
17
|
+
</section>
|
|
18
|
+
<script src="{{ asset('run-output.js') }}" defer></script>
|
|
19
|
+
{% endblock %}
|
|
@@ -39,3 +39,9 @@ path = "src/labnotes/daemon/remote_start.py"
|
|
|
39
39
|
rules = ["mutation"]
|
|
40
40
|
lines = [39]
|
|
41
41
|
reason = "the daemon answers a run with 202, 400, 409 or 500, so every status but 202 is above it: != equals >"
|
|
42
|
+
|
|
43
|
+
[[python.exempt]]
|
|
44
|
+
path = "src/labnotes/run_control.py"
|
|
45
|
+
rules = ["mutation"]
|
|
46
|
+
lines = [38]
|
|
47
|
+
reason = "argument counts are small ints, which CPython caches, so is not equals !="
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Safari ignores position: relative on <tr>, so a stretched ::after link covers the whole page.
|
|
2
|
-
for (const row of document.querySelectorAll(".ledger tbody tr")) {
|
|
3
|
-
const link = row.querySelector(".title-link");
|
|
4
|
-
row.addEventListener("click", (event) => {
|
|
5
|
-
if (event.target.closest("a") || getSelection().toString()) return;
|
|
6
|
-
if (event.metaKey || event.ctrlKey) window.open(link.href, "_blank");
|
|
7
|
-
else location.href = link.href;
|
|
8
|
-
});
|
|
9
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|