labnotes 0.0.73__tar.gz → 0.0.75__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 (136) hide show
  1. {labnotes-0.0.73 → labnotes-0.0.75}/.gitignore +4 -0
  2. {labnotes-0.0.73 → labnotes-0.0.75}/PKG-INFO +2 -1
  3. {labnotes-0.0.73 → labnotes-0.0.75}/pyproject.toml +1 -0
  4. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/context.md +4 -0
  5. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/create_app.py +2 -0
  6. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/daemon_app.py +19 -2
  7. labnotes-0.0.75/src/labnotes/daemon/describe_status.py +14 -0
  8. labnotes-0.0.75/src/labnotes/daemon/run_logs.py +54 -0
  9. labnotes-0.0.75/src/labnotes/daemon/watch_daemon.py +52 -0
  10. labnotes-0.0.75/src/labnotes/daemon/watch_status.py +19 -0
  11. labnotes-0.0.75/src/labnotes/format_started.py +8 -0
  12. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/run_command.py +4 -0
  13. labnotes-0.0.75/src/labnotes/run_status.py +29 -0
  14. labnotes-0.0.75/src/labnotes/static/local-time.js +18 -0
  15. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/templates/experiment.html +3 -2
  16. {labnotes-0.0.73 → labnotes-0.0.75}/testing-conventions.toml +1 -1
  17. {labnotes-0.0.73 → labnotes-0.0.75}/tests/integration/conftest.py +7 -2
  18. {labnotes-0.0.73 → labnotes-0.0.75}/uv.lock +13 -0
  19. {labnotes-0.0.73 → labnotes-0.0.75}/CHANGELOG.md +0 -0
  20. {labnotes-0.0.73 → labnotes-0.0.75}/MIGRATIONS.md +0 -0
  21. {labnotes-0.0.73 → labnotes-0.0.75}/README.md +0 -0
  22. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/__init__.py +0 -0
  23. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/__main__.py +0 -0
  24. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/asset_url.py +0 -0
  25. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/check_counts.py +0 -0
  26. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/cli.py +0 -0
  27. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/context_guide.py +0 -0
  28. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/count_runs.py +0 -0
  29. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/create_experiment.py +0 -0
  30. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/create_note.py +0 -0
  31. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/__init__.py +0 -0
  32. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/daemon_client.py +0 -0
  33. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/daemon_running.py +0 -0
  34. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/daemon_server.py +0 -0
  35. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/ensure_daemon.py +0 -0
  36. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/exit_when_idle.py +0 -0
  37. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/remote_start.py +0 -0
  38. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/run_daemon.py +0 -0
  39. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/serve_daemon.py +0 -0
  40. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/socket_path.py +0 -0
  41. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon/stop_runs.py +0 -0
  42. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/daemon_command.py +0 -0
  43. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/data_html.py +0 -0
  44. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/default_config_text.py +0 -0
  45. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/default_counts.py +0 -0
  46. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/drain_watch.py +0 -0
  47. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/experiment_notes.py +0 -0
  48. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/experiment_runs.py +0 -0
  49. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/experiments.py +0 -0
  50. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/file_changes.py +0 -0
  51. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/format_duration.py +0 -0
  52. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/format_value.py +0 -0
  53. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/group_runs.py +0 -0
  54. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/init.py +0 -0
  55. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/invocation_started.py +0 -0
  56. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/is_positive_int.py +0 -0
  57. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/list_experiments.py +0 -0
  58. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/list_runs.py +0 -0
  59. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/load_config.py +0 -0
  60. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/load_experiment.py +0 -0
  61. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/models.py +0 -0
  62. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/modes.py +0 -0
  63. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/nests_runs.py +0 -0
  64. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/note_path.py +0 -0
  65. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/notebook_kernel.py +0 -0
  66. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/output_html.py +0 -0
  67. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/parse_run_view.py +0 -0
  68. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/print_rows.py +0 -0
  69. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/python_blocks.py +0 -0
  70. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/read_config.py +0 -0
  71. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/read_path.py +0 -0
  72. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/render_markdown.py +0 -0
  73. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/__init__.py +0 -0
  74. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/describe_event.py +0 -0
  75. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/execute_run.py +0 -0
  76. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/hold_folder.py +0 -0
  77. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/new_param_run_id.py +0 -0
  78. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/new_run_id.py +0 -0
  79. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  80. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/param_args.py +0 -0
  81. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/param_grid.py +0 -0
  82. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/param_run_name.py +0 -0
  83. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/plan_runs.py +0 -0
  84. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/prepare_experiment.py +0 -0
  85. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/read_experiment_config.py +0 -0
  86. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/run_experiment.py +0 -0
  87. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/run_folder.py +0 -0
  88. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/run_headless.py +0 -0
  89. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/runs_parent.py +0 -0
  90. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/shell_command.py +0 -0
  91. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/signal_tree.py +0 -0
  92. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/stop_then_kill.py +0 -0
  93. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/wait_for_tree.py +0 -0
  94. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/runner/write_run_json.py +0 -0
  95. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/save_conflict_copy.py +0 -0
  96. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/save_note.py +0 -0
  97. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/saved_outputs.py +0 -0
  98. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/serve_app.py +0 -0
  99. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/server.py +0 -0
  100. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/sort_href.py +0 -0
  101. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/sql_quote.py +0 -0
  102. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/static/experiment.js +0 -0
  103. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/static/labnotes.css +0 -0
  104. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/static/new-note.js +0 -0
  105. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/static/note-edit.js +0 -0
  106. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/static/note-editor.js +0 -0
  107. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/static/row-link.js +0 -0
  108. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/static/run-cell.js +0 -0
  109. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/static/view-toggle.js +0 -0
  110. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/summarize_config.py +0 -0
  111. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/__init__.py +0 -0
  112. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/experiment_rows.py +0 -0
  113. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/experiment_run_ids.py +0 -0
  114. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/file_prefix.py +0 -0
  115. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/file_text.py +0 -0
  116. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/iso_time.py +0 -0
  117. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/list_experiment_runs.py +0 -0
  118. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/note_text.py +0 -0
  119. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/open_tables.py +0 -0
  120. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/path_glob.py +0 -0
  121. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/path_pattern.py +0 -0
  122. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/read_experiment.py +0 -0
  123. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/read_file.py +0 -0
  124. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/read_run.py +0 -0
  125. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tables/summarize_experiments.py +0 -0
  126. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/templates/base.html +0 -0
  127. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/templates/editor_imports.html +0 -0
  128. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/templates/index.html +0 -0
  129. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/templates/note.html +0 -0
  130. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tui/__init__.py +0 -0
  131. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tui/dim.py +0 -0
  132. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tui/format_elapsed.py +0 -0
  133. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tui/run_app.py +0 -0
  134. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tui/run_tui.py +0 -0
  135. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/tui/runs_table.py +0 -0
  136. {labnotes-0.0.73 → labnotes-0.0.75}/src/labnotes/wait_for_note.py +0 -0
@@ -24,3 +24,7 @@ docs/.vitepress/dist
24
24
  docs/node_modules/
25
25
 
26
26
  notes
27
+
28
+ # Written by Claude Code when it loads the labnotes mod
29
+ skills/labnotes/.claude-plugin/types/
30
+ skills/labnotes/tsconfig.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.73
3
+ Version: 0.0.75
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>
@@ -14,6 +14,7 @@ Requires-Dist: ipykernel>=7.4.0
14
14
  Requires-Dist: jinja2>=3.1.6
15
15
  Requires-Dist: jupyter-client>=8.10.0
16
16
  Requires-Dist: markdown-it-py>=4.2.0
17
+ Requires-Dist: markupsafe>=3.0.4
17
18
  Requires-Dist: psutil>=7.2.2
18
19
  Requires-Dist: rich>=14
19
20
  Requires-Dist: starlette>=1.8.0
@@ -19,6 +19,7 @@ dependencies = [
19
19
  "jinja2>=3.1.6",
20
20
  "jupyter-client>=8.10.0",
21
21
  "markdown-it-py>=4.2.0",
22
+ "markupsafe>=3.0.4",
22
23
  "psutil>=7.2.2",
23
24
  "rich>=14",
24
25
  "starlette>=1.8.0",
@@ -122,6 +122,10 @@ going. Elsewhere, it prints a line per event instead.
122
122
  seconds later. Once the runs are done, either one closes the live view.
123
123
  - `labnotes run stop` stops every run the daemon has going: SIGTERM, then
124
124
  SIGKILL 5 seconds later.
125
+ - `labnotes run status` prints what the daemon has going, a line per event so
126
+ far plus the latest line each running run logged. `-f` keeps following, and
127
+ waits for a daemon when none is up. `--json` prints one JSON object per line.
128
+ Following doesn't keep the daemon up.
125
129
  - A second `run` of the same experiment fails while the first is going.
126
130
  - It exits 0 even if runs fail; the summary line counts failures.
127
131
 
@@ -24,6 +24,7 @@ from labnotes.drain_watch import drain_watch
24
24
  from labnotes.experiment_notes import experiment_notes
25
25
  from labnotes.file_changes import file_changes
26
26
  from labnotes.format_duration import format_duration
27
+ from labnotes.format_started import format_started
27
28
  from labnotes.format_value import format_value
28
29
  from labnotes.list_experiments import list_experiments
29
30
  from labnotes.load_experiment import load_experiment
@@ -42,6 +43,7 @@ HERE = Path(__file__).parent
42
43
  templates = Jinja2Templates(directory=HERE / "templates")
43
44
  templates.env.filters["value"] = format_value
44
45
  templates.env.filters["duration"] = format_duration
46
+ templates.env.filters["started"] = format_started
45
47
  templates.env.globals["asset"] = asset_url
46
48
 
47
49
 
@@ -23,6 +23,8 @@ def daemon_app(config: LabnotesConfig) -> Starlette:
23
23
  sessions: dict[str, Controls | None] = {}
24
24
  owners: dict[str, int] = {}
25
25
  clients: dict[int, asyncio.Queue[Event]] = {}
26
+ watchers: set[int] = set()
27
+ history: dict[str, list[Event]] = {}
26
28
  ids = itertools.count(1)
27
29
  tasks: set[asyncio.Task[str | None]] = set()
28
30
 
@@ -40,11 +42,22 @@ def daemon_app(config: LabnotesConfig) -> Starlette:
40
42
  del owners[name]
41
43
  controls.finish()
42
44
 
45
+ def record(event: Event) -> None:
46
+ history.setdefault(str(event["experiment"]), []).append(event)
47
+ broadcast(event)
48
+
43
49
  async def events(request: Request) -> Response:
44
50
  client = next(ids)
51
+ watching = request.query_params.get("watch") == "1"
45
52
 
46
53
  async def stream() -> AsyncIterator[str]:
47
54
  clients[client] = asyncio.Queue()
55
+ if watching:
56
+ watchers.add(client)
57
+ for past in history.values():
58
+ for old in past:
59
+ clients[client].put_nowait(old)
60
+ clients[client].put_nowait({"event": "replayed"})
48
61
  try:
49
62
  event: Event = {"event": "connected", "client": client}
50
63
  while True:
@@ -52,6 +65,7 @@ def daemon_app(config: LabnotesConfig) -> Starlette:
52
65
  event = await clients[client].get()
53
66
  finally:
54
67
  del clients[client]
68
+ watchers.discard(client)
55
69
  release(client)
56
70
 
57
71
  return StreamingResponse(stream(), media_type="text/event-stream")
@@ -86,7 +100,7 @@ def daemon_app(config: LabnotesConfig) -> Starlette:
86
100
  name,
87
101
  experiment,
88
102
  concurrency,
89
- lambda event: broadcast({**event, "experiment": name}),
103
+ lambda event: record({**event, "experiment": name}),
90
104
  listen,
91
105
  )
92
106
  # Clients hear why the session ended; the daemon keeps serving.
@@ -94,6 +108,7 @@ def daemon_app(config: LabnotesConfig) -> Starlette:
94
108
  error = str(failure)
95
109
  del sessions[name]
96
110
  owners.pop(name, None)
111
+ history.pop(name, None)
97
112
  broadcast({"event": "done", "experiment": name, "error": error})
98
113
  return error
99
114
 
@@ -149,6 +164,8 @@ def daemon_app(config: LabnotesConfig) -> Starlette:
149
164
  Route("/experiments/{name}/{action}", control, methods=["POST"]),
150
165
  ]
151
166
  )
152
- app.state.idle = lambda: not clients and not sessions
167
+ app.state.idle = (
168
+ lambda: all(client in watchers for client in clients) and not sessions
169
+ )
153
170
  app.state.stop = stop_all
154
171
  return app
@@ -0,0 +1,14 @@
1
+ from labnotes.runner.describe_event import describe_event
2
+
3
+
4
+ def describe_status(event: dict[str, object]) -> str:
5
+ match event:
6
+ case {"event": "output"}:
7
+ said = f"{event['run']}: {event['line']}"
8
+ case {"event": "done", "error": None}:
9
+ said = "Done."
10
+ case {"event": "done"}:
11
+ said = f"Stopped: {event['error']}"
12
+ case _:
13
+ said = describe_event(event)
14
+ return f"{event['experiment']}: {said}"
@@ -0,0 +1,54 @@
1
+ from pathlib import Path
2
+
3
+ STREAMS = ("stdout", "stderr")
4
+ Key = tuple[str, str]
5
+
6
+
7
+ class RunLogs:
8
+ """The latest line each running run wrote, read from its logs by byte offset."""
9
+
10
+ def __init__(self) -> None:
11
+ self.folders: dict[Key, Path] = {}
12
+ self.offsets: dict[tuple[Key, str], int] = {}
13
+ self.partial: dict[tuple[Key, str], bytes] = {}
14
+
15
+ def add(self, experiment: str, run: str, folder: Path) -> None:
16
+ self.folders[experiment, run] = folder
17
+
18
+ def drop(self, experiment: str, run: str) -> None:
19
+ self.folders.pop((experiment, run), None)
20
+
21
+ def latest(self) -> list[dict[str, object]]:
22
+ found: list[dict[str, object]] = []
23
+ for key, folder in self.folders.items():
24
+ for stream in STREAMS:
25
+ line = self.read(key, stream, folder / f"{stream}.log")
26
+ if line is not None:
27
+ experiment, run = key
28
+ found.append(
29
+ {
30
+ "event": "output",
31
+ "experiment": experiment,
32
+ "run": run,
33
+ "stream": stream,
34
+ "line": line,
35
+ }
36
+ )
37
+ return found
38
+
39
+ def read(self, key: Key, stream: str, path: Path) -> str | None:
40
+ try:
41
+ with path.open("rb") as log:
42
+ log.seek(self.offsets.get((key, stream), 0))
43
+ data = log.read()
44
+ except FileNotFoundError:
45
+ return None
46
+ self.offsets[key, stream] = self.offsets.get((key, stream), 0) + len(data)
47
+ *complete, rest = (self.partial.get((key, stream), b"") + data).split(b"\n")
48
+ self.partial[key, stream] = rest
49
+ shown = [
50
+ text
51
+ for line in complete
52
+ if (text := line.rsplit(b"\r")[-1].decode(errors="replace").strip())
53
+ ]
54
+ return shown[-1] if shown else None
@@ -0,0 +1,52 @@
1
+ import asyncio
2
+ from collections.abc import Callable
3
+ from pathlib import Path
4
+
5
+ import httpx2
6
+
7
+ from labnotes.daemon.daemon_client import daemon_client
8
+ from labnotes.daemon.run_logs import RunLogs
9
+
10
+ Event = dict[str, object]
11
+ TAIL_SECONDS = 1.0
12
+
13
+
14
+ async def watch_daemon(root: Path, follow: bool, emit: Callable[[Event], None]) -> None:
15
+ logs = RunLogs()
16
+
17
+ def tail() -> None:
18
+ for line in logs.latest():
19
+ emit(line)
20
+
21
+ async def keep_tailing() -> None:
22
+ while True:
23
+ await asyncio.sleep(TAIL_SECONDS)
24
+ tail()
25
+
26
+ tailing = asyncio.create_task(keep_tailing()) if follow else None
27
+ try:
28
+ async with (
29
+ daemon_client(root) as client,
30
+ client.stream("GET", "/events", params={"watch": "1"}) as response,
31
+ ):
32
+ events = aiter(httpx2.EventSource(response))
33
+ await anext(events)
34
+ async for message in events:
35
+ event: Event = message.json() # type: ignore[assignment]
36
+ match event:
37
+ case {"event": "replayed"}:
38
+ tail()
39
+ if not follow:
40
+ return
41
+ continue
42
+ case {"event": "started", "experiment": str(name), "run": str(run)}:
43
+ logs.add(name, run, Path(str(event["folder"])))
44
+ case {"event": "ended", "experiment": str(name), "run": str(run)}:
45
+ tail()
46
+ logs.drop(name, run)
47
+ emit(event)
48
+ except httpx2.TransportError:
49
+ pass
50
+ finally:
51
+ if tailing is not None:
52
+ tailing.cancel()
@@ -0,0 +1,19 @@
1
+ import asyncio
2
+ from collections.abc import Callable
3
+ from pathlib import Path
4
+
5
+ from labnotes.daemon.daemon_running import daemon_running
6
+ from labnotes.daemon.watch_daemon import watch_daemon
7
+
8
+ POLL_SECONDS = 1.0
9
+
10
+
11
+ async def watch_status(
12
+ root: Path, follow: bool, emit: Callable[[dict[str, object]], None]
13
+ ) -> None:
14
+ while True:
15
+ if await daemon_running(root):
16
+ await watch_daemon(root, follow, emit)
17
+ if not follow:
18
+ return
19
+ await asyncio.sleep(POLL_SECONDS)
@@ -0,0 +1,8 @@
1
+ from markupsafe import Markup, escape
2
+
3
+
4
+ def format_started(utc: str | None) -> Markup:
5
+ if utc is None:
6
+ return Markup("—")
7
+ stamp = escape(utc)
8
+ return Markup(f'<time datetime="{stamp.replace(" ", "T")}Z">{stamp}</time>')
@@ -5,6 +5,7 @@ import click
5
5
  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
+ from labnotes.run_status import run_status
8
9
 
9
10
 
10
11
  @click.group(name="run", help="Control the runs going in this project.")
@@ -24,3 +25,6 @@ def stop(ctx: click.Context) -> None:
24
25
  )
25
26
  stopping = asyncio.run(stop_runs(config.root))
26
27
  click.echo("Stopping runs." if stopping else "Nothing is running.")
28
+
29
+
30
+ run_command.add_command(run_status)
@@ -0,0 +1,29 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+
5
+ import click
6
+
7
+ from labnotes.daemon.describe_status import describe_status
8
+ from labnotes.daemon.watch_status import watch_status
9
+ from labnotes.load_config import load_config
10
+ from labnotes.read_config import CONFIG_FILE
11
+
12
+
13
+ @click.command(name="status", help="Show what the runs in this project are doing.")
14
+ @click.option("--follow", "-f", is_flag=True, help="Keep reporting until interrupted.")
15
+ @click.option("--json", "as_json", is_flag=True, help="One JSON event per line.")
16
+ @click.pass_context
17
+ def run_status(ctx: click.Context, follow: bool, as_json: bool) -> None:
18
+ config = load_config(ctx)
19
+ if config is None:
20
+ raise click.ClickException(
21
+ f"No {CONFIG_FILE} here. Run labnotes init, or pass -c PATH."
22
+ )
23
+
24
+ def emit(event: dict[str, object]) -> None:
25
+ line = json.dumps(event, default=str) if as_json else describe_status(event)
26
+ click.echo(line)
27
+
28
+ with contextlib.suppress(KeyboardInterrupt):
29
+ asyncio.run(watch_status(config.root, follow, emit))
@@ -0,0 +1,18 @@
1
+ const pad = (number) => String(number).padStart(2, "0");
2
+
3
+ const localize = (root) => {
4
+ for (const time of root.querySelectorAll("time[datetime]")) {
5
+ const at = new Date(time.dateTime);
6
+ const day = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`;
7
+ time.textContent = `${day} ${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
8
+ }
9
+ };
10
+
11
+ localize(document);
12
+ new MutationObserver((records) => {
13
+ for (const record of records) {
14
+ for (const node of record.addedNodes) {
15
+ if (node instanceof Element) localize(node);
16
+ }
17
+ }
18
+ }).observe(document.body, { childList: true, subtree: true });
@@ -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] | value }}</td>{% endif %}{% endfor %}
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 %}
87
87
  </tr>
88
88
  {% endmacro %}
89
89
  {% if nested %}
@@ -93,7 +93,7 @@
93
93
  <td class="num" title="{{ group.passed }} passed, {{ group.failed }} failed"><span class="good">{{ group.passed }}✓</span> <span class="bad">{{ group.failed }}✕</span></td>
94
94
  <td class="mono"><button type="button" class="group-toggle" aria-expanded="false">{{ group.name }}</button></td>
95
95
  <td class="num">{{ group.runs | length }}</td>
96
- {% for column in e.run_columns[1:] %}<td>{{ group.started or '' if column == 'started' }}</td>{% endfor %}
96
+ {% for column in e.run_columns[1:] %}<td>{{ group.started | started if column == 'started' and group.started }}</td>{% endfor %}
97
97
  </tr>
98
98
  {% for run in group.runs %}{{ run_rows(run) }}{% endfor %}
99
99
  </tbody>
@@ -116,4 +116,5 @@
116
116
  </section>
117
117
  {% endif %}
118
118
  <script src="{{ asset('experiment.js') }}" defer></script>
119
+ <script src="{{ asset('local-time.js') }}" defer></script>
119
120
  {% endblock %}
@@ -31,7 +31,7 @@ reason = "quiet counts up by one from zero, so it reaches IDLE_EXIT_SECONDS befo
31
31
  [[python.exempt]]
32
32
  path = "src/labnotes/daemon/daemon_app.py"
33
33
  rules = ["mutation"]
34
- lines = [117]
34
+ lines = [132]
35
35
  reason = "start answers 202, 400, 409 or 500, so every status but 202 is above it: != equals >"
36
36
 
37
37
  [[python.exempt]]
@@ -65,12 +65,17 @@ def serve(root: Path) -> Iterator[str]:
65
65
 
66
66
 
67
67
  def browse(
68
- url: str, check: Callable[[Page], Awaitable[None]], width: int = 1280
68
+ url: str,
69
+ check: Callable[[Page], Awaitable[None]],
70
+ width: int = 1280,
71
+ timezone_id: str = "UTC",
69
72
  ) -> None:
70
73
  async def drive() -> None:
71
74
  async with async_playwright() as playwright:
72
75
  browser = await playwright.chromium.launch()
73
- page = await browser.new_page(viewport={"width": width, "height": 800})
76
+ page = await browser.new_page(
77
+ viewport={"width": width, "height": 800}, timezone_id=timezone_id
78
+ )
74
79
  await page.goto(url)
75
80
  await check(page)
76
81
  await browser.close()
@@ -10,6 +10,17 @@ resolution-markers = [
10
10
  exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
11
11
  exclude-newer-span = "P3D"
12
12
 
13
+ [options.exclude-newer-package]
14
+ botbox = false
15
+ cachetta = false
16
+ coaxer = false
17
+ curtaincall = false
18
+ dirsql = false
19
+ labnotes = false
20
+ lamp-the-djinn = false
21
+ pyskillet = false
22
+ repo-name-checker = false
23
+
13
24
  [[package]]
14
25
  name = "anyio"
15
26
  version = "4.15.1"
@@ -550,6 +561,7 @@ dependencies = [
550
561
  { name = "jinja2" },
551
562
  { name = "jupyter-client" },
552
563
  { name = "markdown-it-py" },
564
+ { name = "markupsafe" },
553
565
  { name = "psutil" },
554
566
  { name = "rich" },
555
567
  { name = "starlette" },
@@ -577,6 +589,7 @@ requires-dist = [
577
589
  { name = "jinja2", specifier = ">=3.1.6" },
578
590
  { name = "jupyter-client", specifier = ">=8.10.0" },
579
591
  { name = "markdown-it-py", specifier = ">=4.2.0" },
592
+ { name = "markupsafe", specifier = ">=3.0.4" },
580
593
  { name = "psutil", specifier = ">=7.2.2" },
581
594
  { name = "rich", specifier = ">=14" },
582
595
  { name = "starlette", specifier = ">=1.8.0" },
File without changes
File without changes
File without changes
File without changes