labnotes 0.0.87__tar.gz → 0.0.89__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 (159) hide show
  1. {labnotes-0.0.87 → labnotes-0.0.89}/PKG-INFO +1 -1
  2. labnotes-0.0.89/src/labnotes/file_changes.py +11 -0
  3. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/experiment_screen.py +21 -9
  4. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/experiments_screen.py +21 -11
  5. labnotes-0.0.89/src/labnotes/tui/fill_table.py +26 -0
  6. labnotes-0.0.89/src/labnotes/tui/reload_on_change.py +22 -0
  7. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/run_app.py +9 -39
  8. labnotes-0.0.89/src/labnotes/tui/run_output.py +41 -0
  9. labnotes-0.0.89/src/labnotes/watch_rows.py +13 -0
  10. {labnotes-0.0.87 → labnotes-0.0.89}/testing-conventions.toml +2 -2
  11. labnotes-0.0.87/src/labnotes/file_changes.py +0 -11
  12. {labnotes-0.0.87 → labnotes-0.0.89}/.gitignore +0 -0
  13. {labnotes-0.0.87 → labnotes-0.0.89}/CHANGELOG.md +0 -0
  14. {labnotes-0.0.87 → labnotes-0.0.89}/MIGRATIONS.md +0 -0
  15. {labnotes-0.0.87 → labnotes-0.0.89}/README.md +0 -0
  16. {labnotes-0.0.87 → labnotes-0.0.89}/pyproject.toml +0 -0
  17. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/__init__.py +0 -0
  18. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/__main__.py +0 -0
  19. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/asset_url.py +0 -0
  20. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/check_counts.py +0 -0
  21. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/cli.py +0 -0
  22. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/context.md +0 -0
  23. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/context_guide.py +0 -0
  24. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/count_groups.py +0 -0
  25. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/count_runs.py +0 -0
  26. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/create_app.py +0 -0
  27. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/create_experiment.py +0 -0
  28. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/create_note.py +0 -0
  29. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/__init__.py +0 -0
  30. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/control_run.py +0 -0
  31. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/daemon_app.py +0 -0
  32. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/daemon_client.py +0 -0
  33. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/daemon_running.py +0 -0
  34. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/daemon_server.py +0 -0
  35. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/describe_status.py +0 -0
  36. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/ensure_daemon.py +0 -0
  37. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/exit_when_idle.py +0 -0
  38. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/remote_start.py +0 -0
  39. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/run_daemon.py +0 -0
  40. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/run_logs.py +0 -0
  41. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/serve_daemon.py +0 -0
  42. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/socket_path.py +0 -0
  43. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/stop_runs.py +0 -0
  44. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/watch_daemon.py +0 -0
  45. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon/watch_status.py +0 -0
  46. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/daemon_command.py +0 -0
  47. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/data_html.py +0 -0
  48. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/default_config_text.py +0 -0
  49. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/default_counts.py +0 -0
  50. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/drain_watch.py +0 -0
  51. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/experiment_notes.py +0 -0
  52. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/experiment_runs.py +0 -0
  53. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/experiments.py +0 -0
  54. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/format_duration.py +0 -0
  55. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/format_started.py +0 -0
  56. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/format_value.py +0 -0
  57. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/group_runs.py +0 -0
  58. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/init.py +0 -0
  59. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/invocation_started.py +0 -0
  60. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/is_positive_int.py +0 -0
  61. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/list_experiments.py +0 -0
  62. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/list_runs.py +0 -0
  63. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/load_config.py +0 -0
  64. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/load_experiment.py +0 -0
  65. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/load_run.py +0 -0
  66. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/models.py +0 -0
  67. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/modes.py +0 -0
  68. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/nests_runs.py +0 -0
  69. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/note_path.py +0 -0
  70. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/notebook_kernel.py +0 -0
  71. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/output_html.py +0 -0
  72. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/parse_run_view.py +0 -0
  73. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/print_rows.py +0 -0
  74. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/python_blocks.py +0 -0
  75. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/read_config.py +0 -0
  76. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/read_path.py +0 -0
  77. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/render_markdown.py +0 -0
  78. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/run_command.py +0 -0
  79. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/run_control.py +0 -0
  80. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/run_output.py +0 -0
  81. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/run_status.py +0 -0
  82. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/__init__.py +0 -0
  83. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/describe_event.py +0 -0
  84. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/execute_run.py +0 -0
  85. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/hold_folder.py +0 -0
  86. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/new_param_run_id.py +0 -0
  87. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/new_run_id.py +0 -0
  88. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  89. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/param_args.py +0 -0
  90. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/param_grid.py +0 -0
  91. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/param_run_name.py +0 -0
  92. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/plan_runs.py +0 -0
  93. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/prepare_experiment.py +0 -0
  94. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/read_experiment_config.py +0 -0
  95. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/run_experiment.py +0 -0
  96. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/run_folder.py +0 -0
  97. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/run_headless.py +0 -0
  98. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/runs_parent.py +0 -0
  99. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/shell_command.py +0 -0
  100. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/signal_tree.py +0 -0
  101. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/stop_then_kill.py +0 -0
  102. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/wait_for_tree.py +0 -0
  103. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/runner/write_run_json.py +0 -0
  104. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/save_conflict_copy.py +0 -0
  105. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/save_note.py +0 -0
  106. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/saved_outputs.py +0 -0
  107. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/serve_app.py +0 -0
  108. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/server.py +0 -0
  109. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/shared_params.py +0 -0
  110. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/sort_href.py +0 -0
  111. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/sql_quote.py +0 -0
  112. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/elapsed.js +0 -0
  113. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/experiment.js +0 -0
  114. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/labnotes.css +0 -0
  115. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/local-time.js +0 -0
  116. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/new-note.js +0 -0
  117. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/note-edit.js +0 -0
  118. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/note-editor.js +0 -0
  119. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/row-link.js +0 -0
  120. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/run-cell.js +0 -0
  121. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/run-output.js +0 -0
  122. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/static/view-toggle.js +0 -0
  123. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/summarize_config.py +0 -0
  124. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/__init__.py +0 -0
  125. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/experiment_rows.py +0 -0
  126. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/experiment_run_ids.py +0 -0
  127. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/file_prefix.py +0 -0
  128. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/file_text.py +0 -0
  129. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/iso_time.py +0 -0
  130. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/list_experiment_runs.py +0 -0
  131. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/note_text.py +0 -0
  132. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/open_tables.py +0 -0
  133. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/path_glob.py +0 -0
  134. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/path_pattern.py +0 -0
  135. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/read_experiment.py +0 -0
  136. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/read_file.py +0 -0
  137. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/read_log.py +0 -0
  138. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/read_run.py +0 -0
  139. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tables/summarize_experiments.py +0 -0
  140. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/templates/base.html +0 -0
  141. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/templates/editor_imports.html +0 -0
  142. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/templates/experiment.html +0 -0
  143. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/templates/index.html +0 -0
  144. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/templates/note.html +0 -0
  145. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/templates/run.html +0 -0
  146. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/__init__.py +0 -0
  147. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/browse_app.py +0 -0
  148. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/browse_tui.py +0 -0
  149. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/dim.py +0 -0
  150. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/format_elapsed.py +0 -0
  151. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/group_cells.py +0 -0
  152. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/invocation_columns.py +0 -0
  153. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/invocation_rows.py +0 -0
  154. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/run_cells.py +0 -0
  155. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/run_tui.py +0 -0
  156. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/tui/runs_table.py +0 -0
  157. {labnotes-0.0.87 → labnotes-0.0.89}/src/labnotes/wait_for_note.py +0 -0
  158. {labnotes-0.0.87 → labnotes-0.0.89}/tests/integration/conftest.py +0 -0
  159. {labnotes-0.0.87 → labnotes-0.0.89}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.87
3
+ Version: 0.0.89
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>
@@ -0,0 +1,11 @@
1
+ import json
2
+ from collections.abc import AsyncIterator
3
+
4
+ from dirsql import DirSQL
5
+
6
+ from labnotes.watch_rows import watch_rows
7
+
8
+
9
+ async def file_changes(tables: DirSQL, experiment: str) -> AsyncIterator[str]:
10
+ async for row in watch_rows(tables, {"files"}, experiment):
11
+ yield f"data: {json.dumps(row['filename'])}\n\n"
@@ -10,9 +10,14 @@ from textual.worker import Worker, WorkerState
10
10
 
11
11
  from labnotes.load_experiment import load_experiment
12
12
  from labnotes.models import LabnotesConfig
13
+ from labnotes.tui.fill_table import fill_table
13
14
  from labnotes.tui.invocation_columns import invocation_columns
14
15
  from labnotes.tui.invocation_rows import invocation_rows
16
+ from labnotes.tui.reload_on_change import reload_on_change
15
17
  from labnotes.tui.runs_table import RunsTable
18
+ from labnotes.watch_rows import watch_rows
19
+
20
+ WATCHED = {"runs", "experiment_configs"}
16
21
 
17
22
 
18
23
  class ExperimentScreen(Screen[None]):
@@ -36,7 +41,12 @@ class ExperimentScreen(Screen[None]):
36
41
 
37
42
  def on_mount(self) -> None:
38
43
  self.sub_title = self.experiment_name
39
- self.run_worker(self.load(), exit_on_error=False)
44
+ self.run_worker(
45
+ reload_on_change(
46
+ watch_rows(self.tables, WATCHED, self.experiment_name), self.load
47
+ ),
48
+ exit_on_error=False,
49
+ )
40
50
 
41
51
  def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
42
52
  if event.state == WorkerState.ERROR:
@@ -50,20 +60,22 @@ class ExperimentScreen(Screen[None]):
50
60
  self.experiment_name,
51
61
  QueryParams(),
52
62
  )
53
- if loaded is None:
63
+ if loaded is None and not self.experiment:
54
64
  raise ValueError(f"No experiment named {self.experiment_name}.")
65
+ if loaded is None:
66
+ self.sub_title = f"{self.experiment_name} was removed."
67
+ fill_table(self.query_one(DataTable), [], [])
68
+ return
55
69
  self.experiment = loaded
56
70
  self.sub_title = str(loaded["display_name"])
57
- self.query_one(DataTable).add_columns(*invocation_columns(loaded))
58
71
  self.redraw()
59
72
 
60
73
  def redraw(self) -> None:
61
- table = self.query_one(DataTable)
62
- row = table.cursor_row
63
- table.clear()
64
- for key, cells in invocation_rows(self.experiment, self.opened):
65
- table.add_row(*cells, key=key)
66
- table.move_cursor(row=row)
74
+ fill_table(
75
+ self.query_one(DataTable),
76
+ invocation_columns(self.experiment),
77
+ invocation_rows(self.experiment, self.opened),
78
+ )
67
79
 
68
80
  def action_toggle_invocation(self) -> None:
69
81
  table = self.query_one(DataTable)
@@ -7,7 +7,12 @@ from textual.worker import Worker, WorkerState
7
7
  from labnotes.models import LabnotesConfig
8
8
  from labnotes.tables.summarize_experiments import summarize_experiments
9
9
  from labnotes.tui.experiment_screen import ExperimentScreen
10
+ from labnotes.tui.fill_table import fill_table
11
+ from labnotes.tui.reload_on_change import reload_on_change
10
12
  from labnotes.tui.runs_table import RunsTable
13
+ from labnotes.watch_rows import watch_rows
14
+
15
+ WATCHED = {"files", "runs", "experiment_configs"}
11
16
 
12
17
 
13
18
  class ExperimentsScreen(Screen[None]):
@@ -22,7 +27,10 @@ class ExperimentsScreen(Screen[None]):
22
27
  yield Footer()
23
28
 
24
29
  def on_mount(self) -> None:
25
- self.run_worker(self.load(), exit_on_error=False)
30
+ self.run_worker(
31
+ reload_on_change(watch_rows(self.tables, WATCHED), self.load),
32
+ exit_on_error=False,
33
+ )
26
34
 
27
35
  def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
28
36
  if event.state == WorkerState.ERROR:
@@ -30,16 +38,18 @@ class ExperimentsScreen(Screen[None]):
30
38
 
31
39
  async def load(self) -> None:
32
40
  rows = await summarize_experiments(self.tables)
33
- if not rows:
34
- self.sub_title = "No experiments."
35
- return
36
- table = self.query_one(DataTable)
37
- table.add_columns(*rows[0])
38
- for row in rows:
39
- table.add_row(
40
- *("" if value is None else str(value) for value in row.values()),
41
- key=str(row["name"]),
42
- )
41
+ self.sub_title = "" if rows else "No experiments."
42
+ fill_table(
43
+ self.query_one(DataTable),
44
+ list(next(iter(rows), ())),
45
+ [
46
+ (
47
+ str(row["name"]),
48
+ ["" if value is None else str(value) for value in row.values()],
49
+ )
50
+ for row in rows
51
+ ],
52
+ )
43
53
 
44
54
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
45
55
  self.app.push_screen(
@@ -0,0 +1,26 @@
1
+ from collections.abc import Sequence
2
+
3
+ from textual.widgets import DataTable
4
+
5
+
6
+ def fill_table(
7
+ table: DataTable[object],
8
+ columns: Sequence[str],
9
+ rows: Sequence[tuple[str, Sequence[object]]],
10
+ ) -> None:
11
+ # The cursor follows its row by key, so rows arriving above it don't move it.
12
+ row = table.cursor_row
13
+ key = (
14
+ table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
15
+ if table.row_count
16
+ else None
17
+ )
18
+ if [str(column.label) for column in table.columns.values()] == list(columns):
19
+ table.clear()
20
+ else:
21
+ table.clear(columns=True)
22
+ table.add_columns(*columns)
23
+ keys = [each for each, _ in rows]
24
+ for each, cells in rows:
25
+ table.add_row(*cells, key=each)
26
+ table.move_cursor(row=keys.index(key) if key in keys else row)
@@ -0,0 +1,22 @@
1
+ import asyncio
2
+ from collections.abc import AsyncIterable, Awaitable, Callable
3
+
4
+
5
+ async def reload_on_change(
6
+ changes: AsyncIterable[object], reload: Callable[[], Awaitable[None]]
7
+ ) -> None:
8
+ # Changes that land during a reload collapse into one more reload.
9
+ stale = asyncio.Event()
10
+
11
+ async def mark() -> None:
12
+ async for _ in changes:
13
+ stale.set()
14
+
15
+ marker = asyncio.create_task(mark())
16
+ try:
17
+ while True:
18
+ await reload()
19
+ await stale.wait()
20
+ stale.clear()
21
+ finally:
22
+ marker.cancel()
@@ -1,5 +1,4 @@
1
1
  import asyncio
2
- import codecs
3
2
  import signal
4
3
  import time
5
4
  from pathlib import Path
@@ -7,7 +6,7 @@ from pathlib import Path
7
6
  from rich.text import Text
8
7
  from textual.app import App, ComposeResult
9
8
  from textual.binding import Binding
10
- from textual.widgets import DataTable, Footer, Header, Input, Log, Static
9
+ from textual.widgets import DataTable, Footer, Header, Input, Static
11
10
 
12
11
  from labnotes.models import Controls, Start
13
12
  from labnotes.runner.describe_event import describe_event
@@ -15,10 +14,10 @@ from labnotes.runner.stop_then_kill import stop_then_kill
15
14
  from labnotes.runner.param_args import param_args
16
15
  from labnotes.tui.dim import dim
17
16
  from labnotes.tui.format_elapsed import format_elapsed
17
+ from labnotes.tui.run_output import RunOutput
18
18
  from labnotes.tui.runs_table import RunsTable
19
19
 
20
20
  COLUMNS = ("run", "params", "status", "time")
21
- STREAMS = ("stdout", "stderr")
22
21
  PENDING, PASSED, FAILED = "○", "✓", "✗"
23
22
  SPINNER = "|/-\\"
24
23
  SKIPPED, DESELECTED = "–", "⊘"
@@ -80,18 +79,11 @@ class RunApp(App[None]):
80
79
  self.viewing: str | None = None
81
80
  self.grabbed: str | None = None
82
81
  self.grabbed_from: list[str] = []
83
- self.offsets: dict[str, int]
84
- self.decoders = {
85
- stream: codecs.getincrementaldecoder("utf-8")(errors="replace")
86
- for stream in STREAMS
87
- }
88
- self.last_stream: str | None
89
- self.mid_line: bool
90
82
 
91
83
  def compose(self) -> ComposeResult:
92
84
  yield Header()
93
85
  yield RunsTable(id="runs", cursor_type="row")
94
- yield Log(id="output")
86
+ yield RunOutput(id="output")
95
87
  yield Input(id="filter", placeholder="Filter params")
96
88
  yield Static(id="status")
97
89
  yield Footer()
@@ -101,7 +93,7 @@ class RunApp(App[None]):
101
93
  for column in COLUMNS:
102
94
  table.add_column(column, key=column)
103
95
  self.query_one(Input).display = False
104
- self.query_one(Log).display = False
96
+ self.query_one(RunOutput).display = False
105
97
  self.query_one(Header).mount(Static(id="concurrency"))
106
98
  table.set_interval(0.1, self.spin)
107
99
 
@@ -373,44 +365,22 @@ class RunApp(App[None]):
373
365
 
374
366
  def view(self, run: str) -> None:
375
367
  self.viewing = run
376
- self.offsets = dict.fromkeys(STREAMS, 0)
377
- for decoder in self.decoders.values():
378
- decoder.reset()
379
- self.last_stream = None
380
- self.mid_line = False
381
- self.query_one(Log).clear()
368
+ self.query_one(RunOutput).reset()
382
369
  self.query_one(DataTable).display = False
383
- self.query_one(Log).display = True
370
+ self.query_one(RunOutput).display = True
384
371
  self.sub_title = run
385
372
  self.show("")
386
373
  self.refresh_bindings()
387
374
  self.follow()
388
375
 
389
376
  def follow(self) -> None:
390
- if self.viewing not in self.folders:
391
- return
392
- for stream in STREAMS:
393
- path = self.folders[self.viewing] / f"{stream}.log"
394
- if not path.exists():
395
- continue
396
- with path.open("rb") as log:
397
- log.seek(self.offsets[stream])
398
- data = log.read()
399
- self.offsets[stream] += len(data)
400
- text = self.decoders[stream].decode(data)
401
- if not text:
402
- continue
403
- # Don't glue one stream's unfinished line onto the other's output.
404
- if self.mid_line and stream != self.last_stream:
405
- text = "\n" + text
406
- self.query_one(Log).write(text)
407
- self.last_stream = stream
408
- self.mid_line = not text.endswith("\n")
377
+ if self.viewing in self.folders:
378
+ self.query_one(RunOutput).follow(self.folders[self.viewing])
409
379
 
410
380
  def action_leave(self) -> None:
411
381
  if self.viewing is not None:
412
382
  self.viewing = None
413
- self.query_one(Log).display = False
383
+ self.query_one(RunOutput).display = False
414
384
  self.query_one(DataTable).display = True
415
385
  self.sub_title = ""
416
386
  self.refresh_bindings()
@@ -0,0 +1,41 @@
1
+ import codecs
2
+ from pathlib import Path
3
+
4
+ from textual.widgets import Log
5
+
6
+ STREAMS = ("stdout", "stderr")
7
+
8
+
9
+ class RunOutput(Log):
10
+ def __init__(self, id: str | None = None) -> None:
11
+ super().__init__(id=id)
12
+ self.reset()
13
+
14
+ def reset(self) -> None:
15
+ self.offsets = dict.fromkeys(STREAMS, 0)
16
+ self.decoders = {
17
+ stream: codecs.getincrementaldecoder("utf-8")(errors="replace")
18
+ for stream in STREAMS
19
+ }
20
+ self.last_stream: str | None = None
21
+ self.mid_line = False
22
+ self.clear()
23
+
24
+ def follow(self, folder: Path) -> None:
25
+ for stream in STREAMS:
26
+ path = folder / f"{stream}.log"
27
+ if not path.exists():
28
+ continue
29
+ with path.open("rb") as log:
30
+ log.seek(self.offsets[stream])
31
+ data = log.read()
32
+ self.offsets[stream] += len(data)
33
+ text = self.decoders[stream].decode(data)
34
+ if not text:
35
+ continue
36
+ # Don't glue one stream's unfinished line onto the other's output.
37
+ if self.mid_line and stream != self.last_stream:
38
+ text = "\n" + text
39
+ self.write(text)
40
+ self.last_stream = stream
41
+ self.mid_line = not text.endswith("\n")
@@ -0,0 +1,13 @@
1
+ from collections.abc import AsyncIterator, Collection
2
+ from typing import Any
3
+
4
+ from dirsql import DirSQL
5
+
6
+
7
+ async def watch_rows(
8
+ tables: DirSQL, names: Collection[str], experiment: str | None = None
9
+ ) -> AsyncIterator[dict[str, Any]]:
10
+ async for event in tables.watch():
11
+ row = event.row or event.old_row
12
+ if event.table in names and row and experiment in (None, row["experiment"]):
13
+ yield row
@@ -5,9 +5,9 @@ lines = [12]
5
5
  reason = "LOCK_EX and LOCK_NB are disjoint bits, so | equals + and ^"
6
6
 
7
7
  [[python.exempt]]
8
- path = "src/labnotes/tui/run_app.py"
8
+ path = "src/labnotes/tui/run_output.py"
9
9
  rules = ["mutation"]
10
- lines = [311]
10
+ lines = [37]
11
11
  reason = "last_stream is always the same str object taken from STREAMS, so is not equals !="
12
12
 
13
13
  [[python.exempt]]
@@ -1,11 +0,0 @@
1
- import json
2
- from collections.abc import AsyncIterator
3
-
4
- from dirsql import DirSQL
5
-
6
-
7
- async def file_changes(tables: DirSQL, experiment: str) -> AsyncIterator[str]:
8
- async for event in tables.watch():
9
- row = event.row or event.old_row
10
- if event.table == "files" and row and row["experiment"] == experiment:
11
- yield f"data: {json.dumps(row['filename'])}\n\n"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes