labnotes 0.0.112__tar.gz → 0.0.114__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.112 → labnotes-0.0.114}/PKG-INFO +1 -1
- labnotes-0.0.114/src/labnotes/__init__.py +4 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/load_experiment.py +2 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/read_experiment_config.py +11 -0
- labnotes-0.0.114/src/labnotes/runs_view_command.py +4 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/open_tables.py +20 -1
- labnotes-0.0.114/src/labnotes/tables/read_run_file.py +21 -0
- labnotes-0.0.114/src/labnotes/tui/browse_app.py +39 -0
- labnotes-0.0.114/src/labnotes/tui/check_view_response.py +43 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/experiment_screen.py +56 -4
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/experiments_screen.py +10 -2
- labnotes-0.0.114/src/labnotes/tui/format_cell.py +27 -0
- labnotes-0.0.114/src/labnotes/tui/group_cells.py +36 -0
- labnotes-0.0.114/src/labnotes/tui/invocation_columns.py +9 -0
- labnotes-0.0.114/src/labnotes/tui/invocation_rows.py +32 -0
- labnotes-0.0.114/src/labnotes/tui/native_rows.py +24 -0
- labnotes-0.0.114/src/labnotes/tui/run_cells.py +22 -0
- labnotes-0.0.114/src/labnotes/tui/runs_view.py +81 -0
- labnotes-0.0.114/src/labnotes/tui/view_column.py +15 -0
- labnotes-0.0.114/src/labnotes/tui/view_process.py +77 -0
- labnotes-0.0.114/src/labnotes/view/__init__.py +3 -0
- labnotes-0.0.114/src/labnotes/view/answer.py +11 -0
- labnotes-0.0.114/src/labnotes/view/build.py +5 -0
- labnotes-0.0.114/src/labnotes/view/respond.py +13 -0
- labnotes-0.0.114/src/labnotes/view/serve.py +21 -0
- labnotes-0.0.112/src/labnotes/__init__.py +0 -3
- labnotes-0.0.112/src/labnotes/tui/browse_app.py +0 -25
- labnotes-0.0.112/src/labnotes/tui/group_cells.py +0 -27
- labnotes-0.0.112/src/labnotes/tui/invocation_columns.py +0 -9
- labnotes-0.0.112/src/labnotes/tui/invocation_rows.py +0 -32
- labnotes-0.0.112/src/labnotes/tui/run_cells.py +0 -29
- {labnotes-0.0.112 → labnotes-0.0.114}/.gitignore +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/CHANGELOG.md +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/MIGRATIONS.md +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/README.md +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/pyproject.toml +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/__main__.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/cli.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/compose_experiment.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/context.md +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/count_groups.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/create_app.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/create_note.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/__init__.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/control_run.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/daemon_app.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/daemon_client.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/daemon_running.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/daemon_server.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/describe_status.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/ensure_daemon.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/exit_when_idle.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/finalize_lost_runs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/remote_start.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/run_daemon.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/run_logs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/serve_daemon.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/socket_path.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/stop_runs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/watch_daemon.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon/watch_status.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/daemon_command.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/experiments.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/file_changes.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/format_started.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/group_runs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/init.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/invocation_started.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/is_non_negative_int.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/is_non_negative_number.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/load_config.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/load_run.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/log.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/log_path.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/memory_interval.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/models.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/modes.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/nests_runs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/note_path.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/read_config.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/read_path.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/retries.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/run_command.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/run_control.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/run_output.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/run_status.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/__init__.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/describe_event.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/execute_run.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/exit_signal.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/hold_folder.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/invocation_meta.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/new_param_run_id.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/param_run_name.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/prepare_experiment.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/record_memory.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/run_experiment.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/signal_tree.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/wait_for_tree.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/save_conflict_copy.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/save_note.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/server.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/shared_params.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/elapsed.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/experiment.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/labnotes.css +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/local-time.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/new-note.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/note-edit.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/note-editor.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/run-output.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/__init__.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/experiment_rows.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/experiment_run_ids.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/experiment_toml.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/experiments_ddl.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/file_text.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/note_text.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/read_blocks.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/read_log.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/read_memory.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/templates/editor_imports.html +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/templates/experiment.html +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/templates/note.html +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/templates/run.html +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/browse_tui.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/config.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/fill_table.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/leave_prompt.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/log_block.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/log_blocks.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/reload_on_change.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/run_app.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/run_output.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/run_screen.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/tui/stopped_title.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/utils/__init__.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/utils/format_bytes.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/utils/process_alive.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/utils/run_usage.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/utils/tree_rss.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/utils/tree_usage.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/wait_for_note.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/src/labnotes/watch_rows.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/testing-conventions.toml +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/tests/integration/conftest.py +0 -0
- {labnotes-0.0.112 → labnotes-0.0.114}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: labnotes
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.114
|
|
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>
|
|
@@ -19,6 +19,7 @@ from labnotes.nests_runs import nests_runs
|
|
|
19
19
|
from labnotes.parse_run_view import parse_run_view
|
|
20
20
|
from labnotes.python_blocks import python_blocks
|
|
21
21
|
from labnotes.render_markdown import render_markdown
|
|
22
|
+
from labnotes.runs_view_command import runs_view_command
|
|
22
23
|
from labnotes.sort_href import sort_href
|
|
23
24
|
from labnotes.sql_quote import quote_string
|
|
24
25
|
from labnotes.summarize_config import summarize_config
|
|
@@ -88,6 +89,7 @@ async def load_experiment(
|
|
|
88
89
|
"run_columns": shown,
|
|
89
90
|
"run_view": view,
|
|
90
91
|
"run_choices": choices,
|
|
92
|
+
"runs_view": runs_view_command(config),
|
|
91
93
|
"sort_hrefs": {
|
|
92
94
|
column: sort_href(view, column) for column in ["status", *shown]
|
|
93
95
|
},
|
|
@@ -23,7 +23,9 @@ SETTINGS = {
|
|
|
23
23
|
"retries",
|
|
24
24
|
"meta",
|
|
25
25
|
"memory_interval",
|
|
26
|
+
"views",
|
|
26
27
|
}
|
|
28
|
+
VIEWS = {"runs"}
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
def read_experiment_config(
|
|
@@ -79,6 +81,15 @@ def read_experiment_config(
|
|
|
79
81
|
memory_interval = values.get("memory_interval", DEFAULT_MEMORY_INTERVAL)
|
|
80
82
|
if not is_non_negative_number(memory_interval):
|
|
81
83
|
raise ValueError(f"{path}: memory_interval must be 0 or more seconds")
|
|
84
|
+
views = values.get("views", {})
|
|
85
|
+
if not isinstance(views, dict):
|
|
86
|
+
raise ValueError(f"{path}: views must be a table, like [views]")
|
|
87
|
+
unknown = sorted(views.keys() - VIEWS)
|
|
88
|
+
if unknown:
|
|
89
|
+
raise ValueError(f"{path}: unknown keys in views: {', '.join(unknown)}")
|
|
90
|
+
runs = views.get("runs")
|
|
91
|
+
if runs is not None and (not isinstance(runs, str) or not runs.strip()):
|
|
92
|
+
raise ValueError(f"{path}: views.runs must be a command")
|
|
82
93
|
return ExperimentConfig(
|
|
83
94
|
command,
|
|
84
95
|
repeats,
|
|
@@ -12,6 +12,7 @@ from labnotes.tables.read_file import read_file
|
|
|
12
12
|
from labnotes.tables.read_log import read_log
|
|
13
13
|
from labnotes.tables.read_memory import read_memory
|
|
14
14
|
from labnotes.tables.read_run import read_run
|
|
15
|
+
from labnotes.tables.read_run_file import read_run_file
|
|
15
16
|
|
|
16
17
|
FILES = """
|
|
17
18
|
CREATE TABLE files (
|
|
@@ -47,9 +48,26 @@ CREATE TABLE memory (
|
|
|
47
48
|
)
|
|
48
49
|
"""
|
|
49
50
|
|
|
51
|
+
# Every file in a run folder, so a change anywhere in it is a watch event.
|
|
52
|
+
RUN_FILES = """
|
|
53
|
+
CREATE TABLE run_files (
|
|
54
|
+
experiment TEXT, path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER
|
|
55
|
+
)
|
|
56
|
+
"""
|
|
50
57
|
|
|
51
|
-
|
|
58
|
+
|
|
59
|
+
def open_tables(config: LabnotesConfig, run_files: bool = False) -> DirSQL:
|
|
52
60
|
root = config.root
|
|
61
|
+
extra = [
|
|
62
|
+
Table(
|
|
63
|
+
name="run_files",
|
|
64
|
+
ddl=RUN_FILES,
|
|
65
|
+
glob=path_glob(f"{config.runs}/**"),
|
|
66
|
+
on_file=partial(
|
|
67
|
+
read_run_file, root, config.runs, experiment=config.experiment
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
]
|
|
53
71
|
return DirSQL(
|
|
54
72
|
str(root),
|
|
55
73
|
tables=[
|
|
@@ -105,6 +123,7 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
|
|
|
105
123
|
defaults=config.defaults,
|
|
106
124
|
),
|
|
107
125
|
),
|
|
126
|
+
*(extra if run_files else []),
|
|
108
127
|
],
|
|
109
128
|
# Run folders are often gitignored (DVC, large outputs); scan them anyway.
|
|
110
129
|
no_ignore=True,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from labnotes.tables.path_pattern import path_pattern
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def read_run_file(
|
|
7
|
+
root: Path, template: str, path: str, experiment: str | None = None
|
|
8
|
+
) -> list[dict[str, object]]:
|
|
9
|
+
relative = Path(path).relative_to(root).as_posix()
|
|
10
|
+
found = path_pattern(f"{template}/**").fullmatch(relative)
|
|
11
|
+
if found is None:
|
|
12
|
+
return []
|
|
13
|
+
stat = Path(path).stat()
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
"experiment": experiment or found["experiment"],
|
|
17
|
+
"path": relative,
|
|
18
|
+
"size": stat.st_size,
|
|
19
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
20
|
+
}
|
|
21
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from textual.app import App
|
|
2
|
+
from textual.binding import Binding
|
|
3
|
+
|
|
4
|
+
from labnotes.models import LabnotesConfig
|
|
5
|
+
from labnotes.tables.open_tables import open_tables
|
|
6
|
+
from labnotes.tui.experiment_screen import ExperimentScreen
|
|
7
|
+
from labnotes.tui.experiments_screen import ExperimentsScreen
|
|
8
|
+
from labnotes.tui.runs_view import Request
|
|
9
|
+
from labnotes.tui.view_process import ViewProcess
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BrowseApp(App[BaseException | None]):
|
|
13
|
+
BINDINGS = [Binding("ctrl+c", "quit", "Quit")]
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: LabnotesConfig, name: str | None) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.config = config
|
|
18
|
+
self.experiment_name = name
|
|
19
|
+
self.title = config.root.name
|
|
20
|
+
self.views: dict[str, ViewProcess] = {}
|
|
21
|
+
|
|
22
|
+
def on_mount(self) -> None:
|
|
23
|
+
tables = open_tables(self.config, run_files=True)
|
|
24
|
+
self.push_screen(ExperimentsScreen(tables, self.config, self.view_request))
|
|
25
|
+
if self.experiment_name is not None:
|
|
26
|
+
self.push_screen(
|
|
27
|
+
ExperimentScreen(
|
|
28
|
+
tables, self.config, self.experiment_name, self.view_request
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def view_request(self, command: str) -> Request:
|
|
33
|
+
if command not in self.views:
|
|
34
|
+
self.views[command] = ViewProcess(command, self.config.root)
|
|
35
|
+
return self.views[command].request
|
|
36
|
+
|
|
37
|
+
async def on_unmount(self) -> None:
|
|
38
|
+
for process in self.views.values():
|
|
39
|
+
await process.close()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from labnotes.tui.view_column import view_column
|
|
6
|
+
|
|
7
|
+
SHAPE = "the response must be an object with columns and rows"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_view_response(
|
|
11
|
+
reply: str, request: Mapping[str, Any]
|
|
12
|
+
) -> tuple[list[dict[str, str]], dict[str, dict[str, Any]]]:
|
|
13
|
+
try:
|
|
14
|
+
response = json.loads(reply)
|
|
15
|
+
except json.JSONDecodeError as error:
|
|
16
|
+
raise ValueError(f"invalid JSON: {error}") from error
|
|
17
|
+
if not isinstance(response, dict) or response.keys() != {"columns", "rows"}:
|
|
18
|
+
raise ValueError(SHAPE)
|
|
19
|
+
if not isinstance(response["columns"], list):
|
|
20
|
+
raise ValueError("columns must be a list")
|
|
21
|
+
columns = [view_column(entry) for entry in response["columns"]]
|
|
22
|
+
keys = set()
|
|
23
|
+
for each in columns:
|
|
24
|
+
if each["key"] in keys:
|
|
25
|
+
raise ValueError(f"column {each['key']} is listed twice")
|
|
26
|
+
keys.add(each["key"])
|
|
27
|
+
rows = response["rows"]
|
|
28
|
+
if not isinstance(rows, dict):
|
|
29
|
+
raise ValueError("rows must be an object")
|
|
30
|
+
asked = request["rows"]
|
|
31
|
+
for folder in asked:
|
|
32
|
+
if folder not in rows:
|
|
33
|
+
raise ValueError(f"no row for {folder}")
|
|
34
|
+
for folder, row in rows.items():
|
|
35
|
+
if folder not in asked:
|
|
36
|
+
raise ValueError(f"a row for {folder}, which wasn't asked for")
|
|
37
|
+
if not isinstance(row, dict):
|
|
38
|
+
raise ValueError(f"{folder}: the row must be an object")
|
|
39
|
+
# Native values may ride along unshown, so {**row, ...} needs no filtering.
|
|
40
|
+
stray = sorted(row.keys() - keys - asked[folder].keys())
|
|
41
|
+
if stray:
|
|
42
|
+
raise ValueError(f"{folder}: unknown columns {', '.join(stray)}")
|
|
43
|
+
return columns, rows
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
1
2
|
from typing import Any
|
|
2
3
|
|
|
3
4
|
from dirsql import DirSQL
|
|
@@ -5,7 +6,7 @@ from starlette.datastructures import QueryParams
|
|
|
5
6
|
from textual.app import ComposeResult
|
|
6
7
|
from textual.binding import Binding
|
|
7
8
|
from textual.screen import Screen
|
|
8
|
-
from textual.widgets import DataTable, Footer, Header
|
|
9
|
+
from textual.widgets import DataTable, Footer, Header, Static
|
|
9
10
|
from textual.worker import Worker, WorkerState
|
|
10
11
|
|
|
11
12
|
from labnotes.load_experiment import load_experiment
|
|
@@ -13,9 +14,11 @@ from labnotes.models import LabnotesConfig
|
|
|
13
14
|
from labnotes.tui.fill_table import fill_table
|
|
14
15
|
from labnotes.tui.invocation_columns import invocation_columns
|
|
15
16
|
from labnotes.tui.invocation_rows import invocation_rows
|
|
17
|
+
from labnotes.tui.native_rows import native_rows
|
|
16
18
|
from labnotes.tui.reload_on_change import reload_on_change
|
|
17
19
|
from labnotes.tui.run_screen import RunScreen
|
|
18
20
|
from labnotes.tui.runs_table import RunsTable
|
|
21
|
+
from labnotes.tui.runs_view import Request, RunsView
|
|
19
22
|
from labnotes.tui.stopped_title import stopped_title
|
|
20
23
|
from labnotes.watch_rows import watch_rows
|
|
21
24
|
|
|
@@ -23,22 +26,35 @@ WATCHED = {"runs", "experiment_configs"}
|
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
class ExperimentScreen(Screen[None]):
|
|
29
|
+
DEFAULT_CSS = """
|
|
30
|
+
ExperimentScreen RunsTable { height: 1fr; }
|
|
31
|
+
ExperimentScreen #view-status { color: $text-warning; }
|
|
32
|
+
"""
|
|
26
33
|
BINDINGS = [
|
|
27
34
|
Binding("enter", "open_row", "Open", priority=True),
|
|
28
35
|
Binding("escape", "app.pop_screen", "Back"),
|
|
29
36
|
]
|
|
30
37
|
|
|
31
|
-
def __init__(
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
tables: DirSQL,
|
|
41
|
+
config: LabnotesConfig,
|
|
42
|
+
name: str,
|
|
43
|
+
views: Callable[[str], Request],
|
|
44
|
+
) -> None:
|
|
32
45
|
super().__init__()
|
|
33
46
|
self.tables = tables
|
|
34
47
|
self.config = config
|
|
35
48
|
self.experiment_name = name
|
|
49
|
+
self.views = views
|
|
50
|
+
self.view: RunsView | None = None
|
|
36
51
|
self.experiment: dict[str, Any] = {}
|
|
37
52
|
self.opened: set[str] = set()
|
|
38
53
|
|
|
39
54
|
def compose(self) -> ComposeResult:
|
|
40
55
|
yield Header()
|
|
41
56
|
yield RunsTable(id="runs", cursor_type="row")
|
|
57
|
+
yield Static(id="view-status")
|
|
42
58
|
yield Footer()
|
|
43
59
|
|
|
44
60
|
def on_mount(self) -> None:
|
|
@@ -75,14 +91,50 @@ class ExperimentScreen(Screen[None]):
|
|
|
75
91
|
return
|
|
76
92
|
self.experiment = loaded
|
|
77
93
|
self.sub_title = str(loaded["display_name"])
|
|
94
|
+
command = loaded["runs_view"]
|
|
95
|
+
if command is not None and self.view is None:
|
|
96
|
+
self.view = RunsView(self.views(str(command)))
|
|
97
|
+
self.run_worker(self.watch_files(self.view), exit_on_error=False)
|
|
98
|
+
if self.view is not None:
|
|
99
|
+
self.view.update(invocation_columns(loaded), native_rows(loaded))
|
|
100
|
+
self.ask(self.view)
|
|
78
101
|
self.redraw()
|
|
79
102
|
|
|
103
|
+
async def watch_files(self, view: RunsView) -> None:
|
|
104
|
+
async for row in watch_rows(self.tables, {"run_files"}, self.experiment_name):
|
|
105
|
+
view.touch(str(row["path"]))
|
|
106
|
+
self.ask(view)
|
|
107
|
+
|
|
108
|
+
def ask(self, view: RunsView) -> None:
|
|
109
|
+
request = view.next_request()
|
|
110
|
+
if request is not None:
|
|
111
|
+
self.run_worker(self.answer(view, request), exit_on_error=False)
|
|
112
|
+
|
|
113
|
+
async def answer(self, view: RunsView, request: dict[str, Any]) -> None:
|
|
114
|
+
if await view.exchange(request):
|
|
115
|
+
self.redraw()
|
|
116
|
+
else:
|
|
117
|
+
self.show_status()
|
|
118
|
+
self.ask(view)
|
|
119
|
+
|
|
80
120
|
def redraw(self) -> None:
|
|
121
|
+
if self.view is None:
|
|
122
|
+
columns = invocation_columns(self.experiment)
|
|
123
|
+
rows = native_rows(self.experiment)
|
|
124
|
+
else:
|
|
125
|
+
columns, rows = self.view.table()
|
|
81
126
|
fill_table(
|
|
82
127
|
self.query_one(DataTable),
|
|
83
|
-
|
|
84
|
-
invocation_rows(self.experiment, self.opened),
|
|
128
|
+
[column["label"] for column in columns],
|
|
129
|
+
invocation_rows(self.experiment, self.opened, columns, rows),
|
|
85
130
|
)
|
|
131
|
+
self.show_status()
|
|
132
|
+
|
|
133
|
+
def show_status(self) -> None:
|
|
134
|
+
text = "" if self.view is None else self.view.status()
|
|
135
|
+
status = self.query_one("#view-status", Static)
|
|
136
|
+
status.update(text)
|
|
137
|
+
status.display = bool(text)
|
|
86
138
|
|
|
87
139
|
def action_open_row(self) -> None:
|
|
88
140
|
table = self.query_one(DataTable)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
1
3
|
from dirsql import DirSQL
|
|
2
4
|
from textual.app import ComposeResult
|
|
3
5
|
from textual.screen import Screen
|
|
@@ -10,6 +12,7 @@ from labnotes.tui.experiment_screen import ExperimentScreen
|
|
|
10
12
|
from labnotes.tui.fill_table import fill_table
|
|
11
13
|
from labnotes.tui.reload_on_change import reload_on_change
|
|
12
14
|
from labnotes.tui.runs_table import RunsTable
|
|
15
|
+
from labnotes.tui.runs_view import Request
|
|
13
16
|
from labnotes.tui.stopped_title import stopped_title
|
|
14
17
|
from labnotes.watch_rows import watch_rows
|
|
15
18
|
|
|
@@ -17,10 +20,13 @@ WATCHED = {"files", "runs", "experiment_configs"}
|
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
class ExperimentsScreen(Screen[None]):
|
|
20
|
-
def __init__(
|
|
23
|
+
def __init__(
|
|
24
|
+
self, tables: DirSQL, config: LabnotesConfig, views: Callable[[str], Request]
|
|
25
|
+
) -> None:
|
|
21
26
|
super().__init__()
|
|
22
27
|
self.tables = tables
|
|
23
28
|
self.config = config
|
|
29
|
+
self.views = views
|
|
24
30
|
|
|
25
31
|
def compose(self) -> ComposeResult:
|
|
26
32
|
yield Header()
|
|
@@ -57,5 +63,7 @@ class ExperimentsScreen(Screen[None]):
|
|
|
57
63
|
|
|
58
64
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
59
65
|
self.app.push_screen(
|
|
60
|
-
ExperimentScreen(
|
|
66
|
+
ExperimentScreen(
|
|
67
|
+
self.tables, self.config, str(event.row_key.value), self.views
|
|
68
|
+
)
|
|
61
69
|
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
|
|
5
|
+
from labnotes.format_value import format_value
|
|
6
|
+
from labnotes.tui.dim import dim
|
|
7
|
+
from labnotes.tui.format_elapsed import format_elapsed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_cell(key: str, value: object, live: bool) -> object:
|
|
11
|
+
if key == "status" and value == "passed":
|
|
12
|
+
return Text("✓", style="green")
|
|
13
|
+
if key == "status" and value == "failed":
|
|
14
|
+
return Text("✕", style="red")
|
|
15
|
+
if key == "status" and value == "unfinished":
|
|
16
|
+
return Text("◐", style="yellow") if live else dim("–")
|
|
17
|
+
if key == "duration" and isinstance(value, int) and not isinstance(value, bool):
|
|
18
|
+
return format_elapsed(value)
|
|
19
|
+
if key == "started" and isinstance(value, str):
|
|
20
|
+
try:
|
|
21
|
+
started = datetime.fromisoformat(value)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return value
|
|
24
|
+
if started.tzinfo is not None:
|
|
25
|
+
started = started.astimezone(UTC)
|
|
26
|
+
return started.strftime("%Y-%m-%d %H:%M:%S")
|
|
27
|
+
return format_value(value)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
|
|
6
|
+
from labnotes.format_value import format_value
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def group_cells(
|
|
10
|
+
group: Mapping[str, Any],
|
|
11
|
+
columns: Sequence[Mapping[str, str]],
|
|
12
|
+
live: bool,
|
|
13
|
+
opened: bool,
|
|
14
|
+
) -> list[object]:
|
|
15
|
+
cells: list[object] = []
|
|
16
|
+
for column in columns:
|
|
17
|
+
key = column["key"]
|
|
18
|
+
if key == "status":
|
|
19
|
+
status = Text.assemble(
|
|
20
|
+
(f"{group['passed']}✓", "green"), " ", (f"{group['failed']}✕", "red")
|
|
21
|
+
)
|
|
22
|
+
if group["unfinished"]:
|
|
23
|
+
mark, style = ("◐", "yellow") if live else ("–", "dim")
|
|
24
|
+
status.append(f" {group['unfinished']}{mark}", style=style)
|
|
25
|
+
cells.append(status)
|
|
26
|
+
elif key == "id":
|
|
27
|
+
cells.append(f"{'▾' if opened else '▸'} {group['name']}")
|
|
28
|
+
elif key == "runs":
|
|
29
|
+
cells.append(str(len(group["runs"])))
|
|
30
|
+
elif key == "started":
|
|
31
|
+
cells.append(group["started"] or "")
|
|
32
|
+
elif key in group["shared"]:
|
|
33
|
+
cells.append(format_value(group["shared"][key]))
|
|
34
|
+
else:
|
|
35
|
+
cells.append("")
|
|
36
|
+
return cells
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def invocation_columns(experiment: Mapping[str, Any]) -> list[dict[str, str]]:
|
|
6
|
+
keys = list(experiment["run_columns"])
|
|
7
|
+
if experiment["run_groups"] is not None:
|
|
8
|
+
keys.insert(1, "runs")
|
|
9
|
+
return [{"key": "status", "label": ""}, *({"key": k, "label": k} for k in keys)]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from labnotes.tui.group_cells import group_cells
|
|
5
|
+
from labnotes.tui.run_cells import run_cells
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def invocation_rows(
|
|
9
|
+
experiment: Mapping[str, Any],
|
|
10
|
+
opened: Collection[str],
|
|
11
|
+
columns: Sequence[Mapping[str, str]],
|
|
12
|
+
rows: Mapping[str, Mapping[str, object]],
|
|
13
|
+
) -> list[tuple[str, list[object]]]:
|
|
14
|
+
live = experiment["runs_live"]
|
|
15
|
+
groups = experiment["run_groups"]
|
|
16
|
+
|
|
17
|
+
def cells(run: Mapping[str, object], nested: bool) -> list[object]:
|
|
18
|
+
return run_cells(rows[str(run["folder"])], columns, nested, live)
|
|
19
|
+
|
|
20
|
+
if groups is None:
|
|
21
|
+
return [(str(run["id"]), cells(run, False)) for run in experiment["runs_list"]]
|
|
22
|
+
shown = []
|
|
23
|
+
for group in groups:
|
|
24
|
+
if group["flat"]:
|
|
25
|
+
[run, *_] = group["runs"]
|
|
26
|
+
shown.append((str(run["id"]), cells(run, True)))
|
|
27
|
+
continue
|
|
28
|
+
is_open = group["name"] in opened
|
|
29
|
+
shown.append((group["name"], group_cells(group, columns, live, is_open)))
|
|
30
|
+
if is_open:
|
|
31
|
+
shown += [(str(run["id"]), cells(run, True)) for run in group["runs"]]
|
|
32
|
+
return shown
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
JSON_FIELDS = ("params", "meta", "run")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def native_row(run: Mapping[str, Any]) -> dict[str, Any]:
|
|
9
|
+
row = {key: value for key, value in run.items() if not key.startswith("labnotes_")}
|
|
10
|
+
for key in JSON_FIELDS:
|
|
11
|
+
if isinstance(row.get(key), str):
|
|
12
|
+
row[key] = json.loads(row[key])
|
|
13
|
+
if run["labnotes_unfinished"]:
|
|
14
|
+
row["status"] = "unfinished"
|
|
15
|
+
elif run["labnotes_completed"]:
|
|
16
|
+
row["status"] = "passed"
|
|
17
|
+
else:
|
|
18
|
+
row["status"] = "failed"
|
|
19
|
+
row["started"] = run["created"]
|
|
20
|
+
return row
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def native_rows(experiment: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
|
24
|
+
return {str(run["folder"]): native_row(run) for run in experiment["runs_list"]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
|
|
3
|
+
from labnotes.tui.format_cell import format_cell
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_cells(
|
|
7
|
+
row: Mapping[str, object],
|
|
8
|
+
columns: Sequence[Mapping[str, str]],
|
|
9
|
+
nested: bool,
|
|
10
|
+
live: bool,
|
|
11
|
+
) -> list[object]:
|
|
12
|
+
cells: list[object] = []
|
|
13
|
+
for column in columns:
|
|
14
|
+
key = column["key"]
|
|
15
|
+
if key not in row:
|
|
16
|
+
cells.append("")
|
|
17
|
+
elif key == "id" and nested:
|
|
18
|
+
child = str(row[key]).partition("/")[2]
|
|
19
|
+
cells.append(f" {child}" if child else str(row[key]))
|
|
20
|
+
else:
|
|
21
|
+
cells.append(format_cell(key, row[key], live))
|
|
22
|
+
return cells
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from labnotes.tui.check_view_response import check_view_response
|
|
5
|
+
|
|
6
|
+
type Columns = list[dict[str, str]]
|
|
7
|
+
type Rows = dict[str, dict[str, Any]]
|
|
8
|
+
type Request = Callable[[dict[str, Any]], Awaitable[tuple[str, float]]]
|
|
9
|
+
|
|
10
|
+
SLOW_SECONDS = 0.1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunsView:
|
|
14
|
+
"""Track which runs the view worker must see again, and its last good answer."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, request: Request) -> None:
|
|
17
|
+
self.request = request
|
|
18
|
+
self.columns: Columns = []
|
|
19
|
+
self.native: Rows = {}
|
|
20
|
+
self.sent_columns: Columns | None = None
|
|
21
|
+
self.sent: Rows = {}
|
|
22
|
+
self.pending: set[str] = set()
|
|
23
|
+
self.busy = False
|
|
24
|
+
self.shown_columns: Columns | None = None
|
|
25
|
+
self.shown: Rows = {}
|
|
26
|
+
self.error: str | None = None
|
|
27
|
+
self.elapsed: float | None = None
|
|
28
|
+
|
|
29
|
+
def update(self, columns: Columns, native: Rows) -> None:
|
|
30
|
+
if columns != self.sent_columns:
|
|
31
|
+
self.pending |= native.keys()
|
|
32
|
+
self.pending |= {f for f, row in native.items() if self.sent.get(f) != row}
|
|
33
|
+
self.pending &= native.keys()
|
|
34
|
+
self.sent = {f: row for f, row in self.sent.items() if f in native}
|
|
35
|
+
self.shown = {f: row for f, row in self.shown.items() if f in native}
|
|
36
|
+
self.columns, self.native = columns, native
|
|
37
|
+
|
|
38
|
+
def touch(self, path: str) -> None:
|
|
39
|
+
owners = [f for f in self.native if path.startswith(f"{f}/")]
|
|
40
|
+
if owners:
|
|
41
|
+
self.pending.add(max(owners, key=len))
|
|
42
|
+
|
|
43
|
+
def next_request(self) -> dict[str, Any] | None:
|
|
44
|
+
if self.busy or not self.pending:
|
|
45
|
+
return None
|
|
46
|
+
rows = {folder: self.native[folder] for folder in sorted(self.pending)}
|
|
47
|
+
self.pending = set()
|
|
48
|
+
self.busy = True
|
|
49
|
+
self.sent_columns = self.columns
|
|
50
|
+
self.sent.update(rows)
|
|
51
|
+
return {"columns": self.columns, "rows": rows}
|
|
52
|
+
|
|
53
|
+
async def exchange(self, request: dict[str, Any]) -> bool:
|
|
54
|
+
"""Send a request and keep a valid answer; report whether rows changed."""
|
|
55
|
+
try:
|
|
56
|
+
reply, self.elapsed = await self.request(request)
|
|
57
|
+
columns, rows = check_view_response(reply, request)
|
|
58
|
+
except (ValueError, OSError) as error:
|
|
59
|
+
self.error = str(error)
|
|
60
|
+
return False
|
|
61
|
+
finally:
|
|
62
|
+
self.busy = False
|
|
63
|
+
changed = columns != self.shown_columns or any(
|
|
64
|
+
self.shown.get(folder) != row for folder, row in rows.items()
|
|
65
|
+
)
|
|
66
|
+
self.error = None
|
|
67
|
+
self.shown_columns = columns
|
|
68
|
+
self.shown.update(rows)
|
|
69
|
+
return changed
|
|
70
|
+
|
|
71
|
+
def table(self) -> tuple[Columns, Rows]:
|
|
72
|
+
rows = {f: self.shown.get(f, row) for f, row in self.native.items()}
|
|
73
|
+
return self.columns if self.shown_columns is None else self.shown_columns, rows
|
|
74
|
+
|
|
75
|
+
def status(self) -> str:
|
|
76
|
+
notes = []
|
|
77
|
+
if self.error is not None:
|
|
78
|
+
notes.append(f"runs view: {self.error}")
|
|
79
|
+
if self.elapsed is not None and self.elapsed > SLOW_SECONDS:
|
|
80
|
+
notes.append(f"runs view took {round(self.elapsed * 1000)} ms")
|
|
81
|
+
return " · ".join(notes)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
def view_column(entry: object) -> dict[str, str]:
|
|
2
|
+
if isinstance(entry, str):
|
|
3
|
+
return {"key": entry, "label": entry}
|
|
4
|
+
if not isinstance(entry, dict):
|
|
5
|
+
raise ValueError("a column must be a string or an object")
|
|
6
|
+
key = entry.get("key")
|
|
7
|
+
if not isinstance(key, str):
|
|
8
|
+
raise ValueError("a column needs a string key")
|
|
9
|
+
label = entry.get("label", key)
|
|
10
|
+
if not isinstance(label, str):
|
|
11
|
+
raise ValueError(f"column {key}: label must be a string")
|
|
12
|
+
unknown = sorted(entry.keys() - {"key", "label"})
|
|
13
|
+
if unknown:
|
|
14
|
+
raise ValueError(f"column {key}: unknown fields {', '.join(unknown)}")
|
|
15
|
+
return {"key": key, "label": label}
|