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.
Files changed (146) hide show
  1. {labnotes-0.0.77 → labnotes-0.0.79}/PKG-INFO +1 -1
  2. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/context.md +4 -1
  3. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/create_app.py +30 -0
  4. labnotes-0.0.79/src/labnotes/daemon/control_run.py +16 -0
  5. labnotes-0.0.79/src/labnotes/load_run.py +21 -0
  6. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/run_command.py +2 -0
  7. labnotes-0.0.79/src/labnotes/run_control.py +46 -0
  8. labnotes-0.0.79/src/labnotes/run_output.py +66 -0
  9. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/labnotes.css +13 -0
  10. labnotes-0.0.79/src/labnotes/static/row-link.js +8 -0
  11. labnotes-0.0.79/src/labnotes/static/run-output.js +24 -0
  12. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/open_tables.py +14 -0
  13. labnotes-0.0.79/src/labnotes/tables/read_log.py +20 -0
  14. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/experiment.html +2 -1
  15. labnotes-0.0.79/src/labnotes/templates/run.html +19 -0
  16. {labnotes-0.0.77 → labnotes-0.0.79}/testing-conventions.toml +6 -0
  17. labnotes-0.0.77/src/labnotes/static/row-link.js +0 -9
  18. {labnotes-0.0.77 → labnotes-0.0.79}/.gitignore +0 -0
  19. {labnotes-0.0.77 → labnotes-0.0.79}/CHANGELOG.md +0 -0
  20. {labnotes-0.0.77 → labnotes-0.0.79}/MIGRATIONS.md +0 -0
  21. {labnotes-0.0.77 → labnotes-0.0.79}/README.md +0 -0
  22. {labnotes-0.0.77 → labnotes-0.0.79}/pyproject.toml +0 -0
  23. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/__init__.py +0 -0
  24. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/__main__.py +0 -0
  25. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/asset_url.py +0 -0
  26. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/check_counts.py +0 -0
  27. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/cli.py +0 -0
  28. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/context_guide.py +0 -0
  29. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/count_groups.py +0 -0
  30. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/count_runs.py +0 -0
  31. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/create_experiment.py +0 -0
  32. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/create_note.py +0 -0
  33. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/__init__.py +0 -0
  34. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/daemon_app.py +0 -0
  35. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/daemon_client.py +0 -0
  36. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/daemon_running.py +0 -0
  37. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/daemon_server.py +0 -0
  38. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/describe_status.py +0 -0
  39. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/ensure_daemon.py +0 -0
  40. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/exit_when_idle.py +0 -0
  41. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/remote_start.py +0 -0
  42. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/run_daemon.py +0 -0
  43. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/run_logs.py +0 -0
  44. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/serve_daemon.py +0 -0
  45. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/socket_path.py +0 -0
  46. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/stop_runs.py +0 -0
  47. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/watch_daemon.py +0 -0
  48. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon/watch_status.py +0 -0
  49. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/daemon_command.py +0 -0
  50. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/data_html.py +0 -0
  51. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/default_config_text.py +0 -0
  52. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/default_counts.py +0 -0
  53. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/drain_watch.py +0 -0
  54. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/experiment_notes.py +0 -0
  55. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/experiment_runs.py +0 -0
  56. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/experiments.py +0 -0
  57. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/file_changes.py +0 -0
  58. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/format_duration.py +0 -0
  59. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/format_started.py +0 -0
  60. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/format_value.py +0 -0
  61. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/group_runs.py +0 -0
  62. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/init.py +0 -0
  63. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/invocation_started.py +0 -0
  64. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/is_positive_int.py +0 -0
  65. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/list_experiments.py +0 -0
  66. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/list_runs.py +0 -0
  67. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/load_config.py +0 -0
  68. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/load_experiment.py +0 -0
  69. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/models.py +0 -0
  70. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/modes.py +0 -0
  71. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/nests_runs.py +0 -0
  72. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/note_path.py +0 -0
  73. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/notebook_kernel.py +0 -0
  74. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/output_html.py +0 -0
  75. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/parse_run_view.py +0 -0
  76. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/print_rows.py +0 -0
  77. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/python_blocks.py +0 -0
  78. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/read_config.py +0 -0
  79. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/read_path.py +0 -0
  80. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/render_markdown.py +0 -0
  81. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/run_status.py +0 -0
  82. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/__init__.py +0 -0
  83. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/describe_event.py +0 -0
  84. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/execute_run.py +0 -0
  85. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/hold_folder.py +0 -0
  86. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/new_param_run_id.py +0 -0
  87. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/new_run_id.py +0 -0
  88. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  89. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/param_args.py +0 -0
  90. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/param_grid.py +0 -0
  91. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/param_run_name.py +0 -0
  92. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/plan_runs.py +0 -0
  93. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/prepare_experiment.py +0 -0
  94. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/read_experiment_config.py +0 -0
  95. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/run_experiment.py +0 -0
  96. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/run_folder.py +0 -0
  97. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/run_headless.py +0 -0
  98. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/runs_parent.py +0 -0
  99. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/shell_command.py +0 -0
  100. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/signal_tree.py +0 -0
  101. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/stop_then_kill.py +0 -0
  102. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/wait_for_tree.py +0 -0
  103. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/runner/write_run_json.py +0 -0
  104. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/save_conflict_copy.py +0 -0
  105. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/save_note.py +0 -0
  106. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/saved_outputs.py +0 -0
  107. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/serve_app.py +0 -0
  108. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/server.py +0 -0
  109. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/shared_params.py +0 -0
  110. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/sort_href.py +0 -0
  111. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/sql_quote.py +0 -0
  112. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/experiment.js +0 -0
  113. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/local-time.js +0 -0
  114. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/new-note.js +0 -0
  115. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/note-edit.js +0 -0
  116. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/note-editor.js +0 -0
  117. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/run-cell.js +0 -0
  118. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/static/view-toggle.js +0 -0
  119. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/summarize_config.py +0 -0
  120. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/__init__.py +0 -0
  121. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/experiment_rows.py +0 -0
  122. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/experiment_run_ids.py +0 -0
  123. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/file_prefix.py +0 -0
  124. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/file_text.py +0 -0
  125. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/iso_time.py +0 -0
  126. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/list_experiment_runs.py +0 -0
  127. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/note_text.py +0 -0
  128. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/path_glob.py +0 -0
  129. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/path_pattern.py +0 -0
  130. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/read_experiment.py +0 -0
  131. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/read_file.py +0 -0
  132. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/read_run.py +0 -0
  133. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tables/summarize_experiments.py +0 -0
  134. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/base.html +0 -0
  135. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/editor_imports.html +0 -0
  136. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/index.html +0 -0
  137. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/templates/note.html +0 -0
  138. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/__init__.py +0 -0
  139. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/dim.py +0 -0
  140. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/format_elapsed.py +0 -0
  141. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/run_app.py +0 -0
  142. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/run_tui.py +0 -0
  143. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/tui/runs_table.py +0 -0
  144. {labnotes-0.0.77 → labnotes-0.0.79}/src/labnotes/wait_for_note.py +0 -0
  145. {labnotes-0.0.77 → labnotes-0.0.79}/tests/integration/conftest.py +0 -0
  146. {labnotes-0.0.77 → labnotes-0.0.79}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.77
3
+ Version: 0.0.79
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>
@@ -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' and nested %}<td class="mono">{{ run.id.partition('/')[2] or run.id }}</td><td></td>{% 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 %}
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