labnotes 0.2.15__tar.gz → 0.2.17__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.2.15 → labnotes-0.2.17}/PKG-INFO +1 -1
- labnotes-0.2.17/conftest.py +9 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/cli.py +2 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/create_app.py +1 -1
- labnotes-0.2.17/src/labnotes/daemon/__init__.py +3 -0
- labnotes-0.2.17/src/labnotes/daemon/client.py +35 -0
- labnotes-0.2.17/src/labnotes/daemon/command.py +32 -0
- labnotes-0.2.17/src/labnotes/daemon/ensure.py +46 -0
- labnotes-0.2.17/src/labnotes/daemon/marker.py +27 -0
- labnotes-0.2.17/src/labnotes/daemon/server.py +237 -0
- labnotes-0.2.17/src/labnotes/daemon/socket_path.py +51 -0
- labnotes-0.2.17/src/labnotes/daemon_command.py +20 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/experiments.py +35 -3
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/list_experiments.py +15 -4
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/describe_event.py +2 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_experiment.py +27 -18
- labnotes-0.2.17/src/labnotes/runner/run_state.py +62 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/start_experiment.py +18 -2
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/experiment_rows.py +6 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/index.html +1 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/experiments_screen.py +10 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_app.py +26 -4
- {labnotes-0.2.15 → labnotes-0.2.17}/testing-conventions.toml +43 -2
- {labnotes-0.2.15 → labnotes-0.2.17}/.gitignore +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/CHANGELOG.md +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/MIGRATIONS.md +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/README.md +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/pyproject.toml +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/__init__.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/__main__.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/command_line_group.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/compose_experiment.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/config_migration.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/context.md +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/copy_experiment.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/count_groups.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/create_note.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/data_html.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/experiment_definition.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/file_changes.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/format_started.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/format_value.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/get_state.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/grid_cell.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/group_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/init.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/invocation_started.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/is_non_negative_int.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/is_non_negative_number.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/latest_log.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/load_config.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/load_experiment.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/load_run.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/log.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/log_path.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/memory_interval.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/models.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/nests_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/note_path.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/output_html.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/param_names.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/parse_param_value.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/parse_params.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/parse_subset.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/pivot_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/read_config.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/read_path.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/retries.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/run_columns.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/run_output.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/__init__.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/cgroup_memory.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/check_args.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/check_hook_placeholders.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/check_subset.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/claim_run.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/command_flags.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/container_cgroup.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/container_memory.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/continue_run.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/docker_output.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/execute_run.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/exit_signal.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/fill_dirs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/fill_experiment_env.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/finalize_lost_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/first_invocation.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/flag_name.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/group_args.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/hold_experiment.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/in_subset.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/invocation_hook.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/invocation_meta.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/override_flags.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/placeholders.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/prepare_experiment.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/project_snapshot.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/read_experiment_config.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/record_memory.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_git.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/run_locked.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/signal_tree.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/untracked_diff.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/wait_for_tree.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/runs_view_command.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/save_conflict_copy.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/save_note.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/server.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/set_state.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/shared_params.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/state_path.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/elapsed.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/experiment.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/labnotes.css +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/local-time.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/new-note.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/note-edit.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/note-editor.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/run-output.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/__init__.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/experiment_run_ids.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/experiment_toml.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/experiments_ddl.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/file_text.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/list_resumable_runs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/note_text.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/open_tables.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_blocks.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_log.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_memory.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/read_run_file.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/runs_columns.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/editor_imports.html +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/experiment.html +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/note.html +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/templates/run.html +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/browse_app.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/browse_tui.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/check_view_response.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/config.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/experiment_screen.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/fill_table.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/format_cell.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/guard_view.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/leave_prompt.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/load_view.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/log_block.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/log_blocks.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/native_rows.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/reload_on_change.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_cells.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_output.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_panel.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/runs_browser.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/runs_view.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/stopped_title.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/table_columns.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/table_rows.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/view_column.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/view_pane.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/view_process.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/tui/view_switcher.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/__init__.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/format_bytes.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/process_alive.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/run_usage.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/tree_rss.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/utils/tree_usage.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/__init__.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/answer.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/build.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/changed.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/experiment.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/respond.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view/serve.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/view_specs.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/wait_for_note.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/src/labnotes/watch_rows.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/tests/conftest.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/tests/e2e/conftest.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/tests/integration/conftest.py +0 -0
- {labnotes-0.2.15 → labnotes-0.2.17}/uv.lock +0 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@pytest.fixture(autouse=True)
|
|
5
|
+
def state_home(
|
|
6
|
+
tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
|
|
7
|
+
) -> None:
|
|
8
|
+
# Run journals live under XDG_STATE_HOME; keep tests out of the real one.
|
|
9
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path_factory.mktemp("state")))
|
|
@@ -4,6 +4,7 @@ import click
|
|
|
4
4
|
|
|
5
5
|
from labnotes.command_line_group import CommandLineGroup
|
|
6
6
|
from labnotes.context_guide import context_guide
|
|
7
|
+
from labnotes.daemon_command import daemon
|
|
7
8
|
from labnotes.experiments import experiments
|
|
8
9
|
from labnotes.init import init
|
|
9
10
|
from labnotes.load_config import load_config
|
|
@@ -42,3 +43,4 @@ def context(ctx: click.Context) -> None:
|
|
|
42
43
|
main.add_command(experiments)
|
|
43
44
|
main.add_command(init)
|
|
44
45
|
main.add_command(server)
|
|
46
|
+
main.add_command(daemon)
|
|
@@ -63,7 +63,7 @@ def create_app(config: LabnotesConfig) -> Starlette:
|
|
|
63
63
|
await kernel.stop()
|
|
64
64
|
|
|
65
65
|
async def index(request: Request) -> Response:
|
|
66
|
-
experiments = await list_experiments(request.app.state.tables)
|
|
66
|
+
experiments = await list_experiments(request.app.state.tables, config)
|
|
67
67
|
return templates.TemplateResponse(
|
|
68
68
|
request,
|
|
69
69
|
"index.html",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Read the socket record and confirm its daemon owns the lock."""
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from labnotes.daemon.socket_path import lock_path, socket_record
|
|
8
|
+
from labnotes.models import LabnotesConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def daemon_socket(config: LabnotesConfig, name: str) -> Path | None:
|
|
12
|
+
"""Return a socket only while its daemon owns the version-keyed lock."""
|
|
13
|
+
record = socket_record(config, name)
|
|
14
|
+
try:
|
|
15
|
+
path = Path(record.read_text().strip())
|
|
16
|
+
except FileNotFoundError:
|
|
17
|
+
return None
|
|
18
|
+
lock = lock_path(config, name)
|
|
19
|
+
try:
|
|
20
|
+
descriptor = os.open(lock, os.O_RDONLY)
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
path.unlink(missing_ok=True)
|
|
23
|
+
record.unlink(missing_ok=True)
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
try:
|
|
27
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
28
|
+
except BlockingIOError:
|
|
29
|
+
return path
|
|
30
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
31
|
+
finally:
|
|
32
|
+
os.close(descriptor)
|
|
33
|
+
path.unlink(missing_ok=True)
|
|
34
|
+
record.unlink(missing_ok=True)
|
|
35
|
+
return None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Exchange one JSON command and one reply with a live daemon."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
from labnotes.daemon.client import daemon_socket
|
|
7
|
+
from labnotes.daemon.socket_path import socket_record
|
|
8
|
+
from labnotes.models import LabnotesConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def command(
|
|
12
|
+
config: LabnotesConfig, name: str, message: dict[str, object]
|
|
13
|
+
) -> dict[str, object]:
|
|
14
|
+
"""Send a request and read exactly one reply from the live daemon."""
|
|
15
|
+
path = daemon_socket(config, name)
|
|
16
|
+
if path is None:
|
|
17
|
+
raise ValueError(f"No daemon is running for {name}.")
|
|
18
|
+
try:
|
|
19
|
+
with socket.socket(socket.AF_UNIX) as client:
|
|
20
|
+
client.settimeout(10)
|
|
21
|
+
client.connect(str(path))
|
|
22
|
+
client.sendall((json.dumps(message) + "\n").encode())
|
|
23
|
+
with client.makefile("r") as replies:
|
|
24
|
+
line = replies.readline()
|
|
25
|
+
except OSError as error:
|
|
26
|
+
path.unlink(missing_ok=True)
|
|
27
|
+
socket_record(config, name).unlink(missing_ok=True)
|
|
28
|
+
raise ValueError(f"No daemon is running for {name}.") from error
|
|
29
|
+
if not line:
|
|
30
|
+
raise ValueError(f"Daemon for {name} closed without a reply.")
|
|
31
|
+
reply: dict[str, object] = json.loads(line)
|
|
32
|
+
return reply
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Start a detached experiment daemon when no live owner exists."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from labnotes.daemon.client import daemon_socket
|
|
8
|
+
from labnotes.daemon.socket_path import discovery_folder
|
|
9
|
+
from labnotes.models import LabnotesConfig
|
|
10
|
+
|
|
11
|
+
START_SECONDS = 10
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def ensure_daemon(config: LabnotesConfig, name: str) -> None:
|
|
15
|
+
"""Return once a detached daemon holds the experiment's socket."""
|
|
16
|
+
if daemon_socket(config, name) is not None:
|
|
17
|
+
return
|
|
18
|
+
folder = discovery_folder(config, name)
|
|
19
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
errors = folder / "daemon.stderr"
|
|
21
|
+
errors.touch(mode=0o600, exist_ok=True)
|
|
22
|
+
with errors.open("ab") as stream:
|
|
23
|
+
process = subprocess.Popen(
|
|
24
|
+
[
|
|
25
|
+
sys.executable,
|
|
26
|
+
"-m",
|
|
27
|
+
"labnotes",
|
|
28
|
+
"-c",
|
|
29
|
+
str(config.path.resolve()),
|
|
30
|
+
"daemon",
|
|
31
|
+
name,
|
|
32
|
+
],
|
|
33
|
+
cwd=config.root,
|
|
34
|
+
stdin=subprocess.DEVNULL,
|
|
35
|
+
stdout=subprocess.DEVNULL,
|
|
36
|
+
stderr=stream,
|
|
37
|
+
start_new_session=True,
|
|
38
|
+
)
|
|
39
|
+
for _ in range(START_SECONDS * 10):
|
|
40
|
+
if daemon_socket(config, name) is not None:
|
|
41
|
+
return
|
|
42
|
+
if (status := process.poll()) is not None and status != 0:
|
|
43
|
+
detail = errors.read_text().strip()
|
|
44
|
+
raise ValueError(detail or f"Daemon for {name} exited during startup.")
|
|
45
|
+
await asyncio.sleep(0.1)
|
|
46
|
+
raise ValueError(f"Daemon for {name} did not start in {START_SECONDS}s.")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Read the running-cell count from run folders for a live daemon."""
|
|
2
|
+
|
|
3
|
+
from dirsql import DirSQL
|
|
4
|
+
|
|
5
|
+
from labnotes.daemon.client import daemon_socket
|
|
6
|
+
from labnotes.models import LabnotesConfig
|
|
7
|
+
from labnotes.runner.claim_run import LOCK_FILE
|
|
8
|
+
from labnotes.runner.run_locked import run_locked
|
|
9
|
+
from labnotes.sql_quote import quote_string
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def daemon_marker(
|
|
13
|
+
tables: DirSQL, config: LabnotesConfig, name: str
|
|
14
|
+
) -> dict[str, object]:
|
|
15
|
+
"""Mark a live owner and count its unfinished cells from disk-backed rows."""
|
|
16
|
+
if daemon_socket(config, name) is None:
|
|
17
|
+
return {"daemon": False, "running_cells": 0}
|
|
18
|
+
rows = await tables.query(
|
|
19
|
+
"SELECT folder FROM runs"
|
|
20
|
+
f" WHERE experiment = {quote_string(name)} AND ended IS NULL"
|
|
21
|
+
)
|
|
22
|
+
running = 0
|
|
23
|
+
for row in rows:
|
|
24
|
+
folder = config.root / str(row["folder"])
|
|
25
|
+
if (folder / LOCK_FILE).exists() and run_locked(folder):
|
|
26
|
+
running += 1
|
|
27
|
+
return {"daemon": True, "running_cells": running}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Own experiment invocations behind a private request/response socket."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import fcntl
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from labnotes.daemon.socket_path import lock_path, socket_path, socket_record
|
|
14
|
+
from labnotes.models import Controls, LabnotesConfig
|
|
15
|
+
from labnotes.runner.start_experiment import start_experiment
|
|
16
|
+
|
|
17
|
+
IDLE_SECONDS = 5.0
|
|
18
|
+
START_WAIT_SECONDS = 30.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Invocation:
|
|
23
|
+
"""Keep controls with the one invocation that produced a journal."""
|
|
24
|
+
|
|
25
|
+
task: asyncio.Task[None] | None = None
|
|
26
|
+
controls: Controls | None = None
|
|
27
|
+
stop_requested: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExperimentDaemon:
|
|
31
|
+
"""Serve commands while cells run or a client remains attached."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: LabnotesConfig, name: str) -> None:
|
|
34
|
+
self.config = config
|
|
35
|
+
self.name = name
|
|
36
|
+
self.active: dict[str, Invocation] = {}
|
|
37
|
+
self.clients = 0
|
|
38
|
+
self.closing = False
|
|
39
|
+
self.last_busy = time.monotonic()
|
|
40
|
+
|
|
41
|
+
def stop_all(self) -> None:
|
|
42
|
+
"""Ask every invocation to stop, including one still preparing."""
|
|
43
|
+
for invocation in self.active.values():
|
|
44
|
+
invocation.stop_requested = True
|
|
45
|
+
if invocation.controls is not None:
|
|
46
|
+
invocation.controls.interrupt()
|
|
47
|
+
|
|
48
|
+
async def start(self, options: dict[str, object]) -> dict[str, object]:
|
|
49
|
+
"""Start one independent invocation and return its journal path."""
|
|
50
|
+
selected = start_experiment(
|
|
51
|
+
self.config,
|
|
52
|
+
self.name,
|
|
53
|
+
Path(str(options["experiment_config"]))
|
|
54
|
+
if options.get("experiment_config") is not None
|
|
55
|
+
else None,
|
|
56
|
+
cast(int | None, options.get("concurrency")),
|
|
57
|
+
cast(int | None, options.get("timeout")),
|
|
58
|
+
cast(dict[str, list[str]] | None, options.get("only")),
|
|
59
|
+
cast(list[str] | None, options.get("args")),
|
|
60
|
+
cast(str | None, options.get("setup")),
|
|
61
|
+
cast(str | None, options.get("teardown")),
|
|
62
|
+
cast(str | None, options.get("command_line")),
|
|
63
|
+
cast(str | None, options.get("run_id")),
|
|
64
|
+
)
|
|
65
|
+
path = str(selected.run_state.path)
|
|
66
|
+
invocation = Invocation()
|
|
67
|
+
self.active[path] = invocation
|
|
68
|
+
ready: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
69
|
+
|
|
70
|
+
def listen(controls: Controls) -> None:
|
|
71
|
+
invocation.controls = controls
|
|
72
|
+
controls.finish()
|
|
73
|
+
if invocation.stop_requested:
|
|
74
|
+
controls.interrupt()
|
|
75
|
+
if not ready.done():
|
|
76
|
+
ready.set_result(None)
|
|
77
|
+
|
|
78
|
+
async def run() -> None:
|
|
79
|
+
try:
|
|
80
|
+
await selected(lambda event: None, listen)
|
|
81
|
+
except Exception as error:
|
|
82
|
+
if not ready.done():
|
|
83
|
+
ready.set_exception(error)
|
|
84
|
+
else:
|
|
85
|
+
selected.run_state.record(
|
|
86
|
+
{"event": "daemon error", "error": str(error)}
|
|
87
|
+
)
|
|
88
|
+
finally:
|
|
89
|
+
self.active.pop(path, None)
|
|
90
|
+
|
|
91
|
+
task = asyncio.create_task(run())
|
|
92
|
+
invocation.task = task
|
|
93
|
+
try:
|
|
94
|
+
await asyncio.wait_for(ready, timeout=START_WAIT_SECONDS)
|
|
95
|
+
except TimeoutError:
|
|
96
|
+
task.cancel()
|
|
97
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
98
|
+
return {"ok": False, "error": "Experiment preparation timed out"}
|
|
99
|
+
except ValueError as error:
|
|
100
|
+
return {"ok": False, "error": str(error)}
|
|
101
|
+
return {"ok": True, "journal": path}
|
|
102
|
+
|
|
103
|
+
async def dispatch(self, message: dict[str, object]) -> dict[str, object]:
|
|
104
|
+
"""Apply one command and provide one success or error reply."""
|
|
105
|
+
action = message.get("command")
|
|
106
|
+
if action == "ping":
|
|
107
|
+
return {"ok": True}
|
|
108
|
+
if action in ("start", "restart"):
|
|
109
|
+
options = message.get("options", {})
|
|
110
|
+
if not isinstance(options, dict):
|
|
111
|
+
return {"ok": False, "error": "options must be an object"}
|
|
112
|
+
if action == "restart" and "run_id" not in options:
|
|
113
|
+
return {"ok": False, "error": "restart needs run_id"}
|
|
114
|
+
return await self.start(options)
|
|
115
|
+
if action == "stop_all":
|
|
116
|
+
self.stop_all()
|
|
117
|
+
return {"ok": True}
|
|
118
|
+
if action == "stop_one":
|
|
119
|
+
run = message.get("run")
|
|
120
|
+
if not isinstance(run, str):
|
|
121
|
+
return {"ok": False, "error": "stop_one needs a run id"}
|
|
122
|
+
for invocation in self.active.values():
|
|
123
|
+
if invocation.controls is not None:
|
|
124
|
+
invocation.controls.kill(run)
|
|
125
|
+
return {"ok": True}
|
|
126
|
+
if action == "set_concurrency":
|
|
127
|
+
count = message.get("count")
|
|
128
|
+
if not isinstance(count, int) or count < 1:
|
|
129
|
+
return {"ok": False, "error": "count must be positive"}
|
|
130
|
+
journal = message.get("journal")
|
|
131
|
+
targets = (
|
|
132
|
+
[self.active[str(journal)]]
|
|
133
|
+
if journal is not None and str(journal) in self.active
|
|
134
|
+
else list(self.active.values())
|
|
135
|
+
if journal is None
|
|
136
|
+
else []
|
|
137
|
+
)
|
|
138
|
+
if not targets:
|
|
139
|
+
return {"ok": False, "error": "No active invocation"}
|
|
140
|
+
for invocation in targets:
|
|
141
|
+
if invocation.controls is not None:
|
|
142
|
+
invocation.controls.set_concurrency(count)
|
|
143
|
+
return {"ok": True}
|
|
144
|
+
if action == "attach":
|
|
145
|
+
return {"ok": True}
|
|
146
|
+
return {"ok": False, "error": f"Unknown command {action}"}
|
|
147
|
+
|
|
148
|
+
async def handle(
|
|
149
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Hold an attach connection open without streaming events to it."""
|
|
152
|
+
self.clients += 1
|
|
153
|
+
try:
|
|
154
|
+
line = await reader.readline()
|
|
155
|
+
if not line:
|
|
156
|
+
return
|
|
157
|
+
message = json.loads(line)
|
|
158
|
+
if not isinstance(message, dict):
|
|
159
|
+
reply = {"ok": False, "error": "Request must be an object"}
|
|
160
|
+
else:
|
|
161
|
+
try:
|
|
162
|
+
reply = await self.dispatch(message)
|
|
163
|
+
except Exception as error:
|
|
164
|
+
# A request always gets one error reply, even on internal failures.
|
|
165
|
+
reply = {"ok": False, "error": str(error)}
|
|
166
|
+
writer.write((json.dumps(reply) + "\n").encode())
|
|
167
|
+
await writer.drain()
|
|
168
|
+
if isinstance(message, dict) and message.get("command") == "attach":
|
|
169
|
+
await reader.read()
|
|
170
|
+
except (json.JSONDecodeError, asyncio.LimitOverrunError):
|
|
171
|
+
writer.write(b'{"ok":false,"error":"Invalid request"}\n')
|
|
172
|
+
await writer.drain()
|
|
173
|
+
finally:
|
|
174
|
+
self.clients -= 1
|
|
175
|
+
writer.close()
|
|
176
|
+
await writer.wait_closed()
|
|
177
|
+
|
|
178
|
+
async def wait_until_idle(self) -> None:
|
|
179
|
+
"""Leave a short window for another TUI after the last cell ends."""
|
|
180
|
+
while True:
|
|
181
|
+
if self.closing:
|
|
182
|
+
return
|
|
183
|
+
if self.active or self.clients:
|
|
184
|
+
self.last_busy = time.monotonic()
|
|
185
|
+
elif time.monotonic() - self.last_busy >= IDLE_SECONDS:
|
|
186
|
+
return
|
|
187
|
+
await asyncio.sleep(0.2)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def serve_daemon(config: LabnotesConfig, name: str) -> None:
|
|
191
|
+
"""Hold the experiment/version lock for one Unix server lifetime."""
|
|
192
|
+
lock = lock_path(config, name)
|
|
193
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
lock.touch(mode=0o600, exist_ok=True)
|
|
195
|
+
descriptor = os.open(lock, os.O_RDONLY)
|
|
196
|
+
try:
|
|
197
|
+
for _ in range(10):
|
|
198
|
+
try:
|
|
199
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
200
|
+
except BlockingIOError:
|
|
201
|
+
await asyncio.sleep(0.1)
|
|
202
|
+
else:
|
|
203
|
+
break
|
|
204
|
+
else:
|
|
205
|
+
return
|
|
206
|
+
path = socket_path(config, name)
|
|
207
|
+
path.unlink(missing_ok=True)
|
|
208
|
+
daemon = ExperimentDaemon(config, name)
|
|
209
|
+
server = await asyncio.start_unix_server(daemon.handle, path=str(path))
|
|
210
|
+
path.chmod(0o600)
|
|
211
|
+
record = socket_record(config, name)
|
|
212
|
+
pending = record.with_suffix(".socket.tmp")
|
|
213
|
+
pending.write_text(str(path), encoding="utf-8")
|
|
214
|
+
pending.chmod(0o600)
|
|
215
|
+
pending.replace(record)
|
|
216
|
+
loop = asyncio.get_running_loop()
|
|
217
|
+
|
|
218
|
+
def close() -> None:
|
|
219
|
+
daemon.closing = True
|
|
220
|
+
daemon.stop_all()
|
|
221
|
+
|
|
222
|
+
loop.add_signal_handler(signal.SIGTERM, close)
|
|
223
|
+
loop.add_signal_handler(signal.SIGINT, close)
|
|
224
|
+
loop.add_signal_handler(signal.SIGHUP, lambda: None)
|
|
225
|
+
try:
|
|
226
|
+
await daemon.wait_until_idle()
|
|
227
|
+
finally:
|
|
228
|
+
server.close()
|
|
229
|
+
await server.wait_closed()
|
|
230
|
+
daemon.stop_all()
|
|
231
|
+
await asyncio.gather(
|
|
232
|
+
*(item.task for item in daemon.active.values() if item.task is not None)
|
|
233
|
+
)
|
|
234
|
+
record.unlink(missing_ok=True)
|
|
235
|
+
path.unlink(missing_ok=True)
|
|
236
|
+
finally:
|
|
237
|
+
os.close(descriptor)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Locate the private Unix socket and its on-disk discovery record."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from labnotes.models import LabnotesConfig
|
|
10
|
+
from labnotes.runner.run_state import journal_folder
|
|
11
|
+
from labnotes.runner.runs_parent import runs_parent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def daemon_key(config: LabnotesConfig, name: str) -> str:
|
|
15
|
+
"""Key an experiment by its resolved path and installed package version."""
|
|
16
|
+
folder = config.root / config.experiments.replace("{experiment}", name)
|
|
17
|
+
identity = f"{folder.resolve()}\0{version('labnotes')}"
|
|
18
|
+
return hashlib.sha256(identity.encode()).hexdigest()[:16]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def runtime_folder() -> Path:
|
|
22
|
+
"""Keep socket names short even when the experiment path is deep."""
|
|
23
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
24
|
+
folder = (
|
|
25
|
+
Path(runtime) / "labnotes"
|
|
26
|
+
if runtime
|
|
27
|
+
else Path(tempfile.gettempdir()) / f"labnotes-{os.getuid()}"
|
|
28
|
+
)
|
|
29
|
+
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
30
|
+
folder.chmod(0o700)
|
|
31
|
+
return folder
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def socket_path(config: LabnotesConfig, name: str) -> Path:
|
|
35
|
+
"""Return the version-keyed bind path outside the experiment tree."""
|
|
36
|
+
return runtime_folder() / f"{daemon_key(config, name)}.sock"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def discovery_folder(config: LabnotesConfig, name: str) -> Path:
|
|
40
|
+
"""Find daemon metadata alongside the experiment's run journals."""
|
|
41
|
+
return journal_folder(runs_parent(config.root, config.runs, name))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def socket_record(config: LabnotesConfig, name: str) -> Path:
|
|
45
|
+
"""Point clients to the current socket without guessing runtime settings."""
|
|
46
|
+
return discovery_folder(config, name) / f"{daemon_key(config, name)}.socket"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lock_path(config: LabnotesConfig, name: str) -> Path:
|
|
50
|
+
"""Serialize one daemon per experiment and package version."""
|
|
51
|
+
return discovery_folder(config, name) / f"{daemon_key(config, name)}.lock"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Internal CLI entry point for a detached experiment daemon."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from labnotes.daemon.server import serve_daemon
|
|
8
|
+
from labnotes.load_config import load_config
|
|
9
|
+
from labnotes.read_config import CONFIG_FILE
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command(hidden=True)
|
|
13
|
+
@click.argument("name")
|
|
14
|
+
@click.pass_context
|
|
15
|
+
def daemon(ctx: click.Context, name: str) -> None:
|
|
16
|
+
"""Serve one experiment from a detached process."""
|
|
17
|
+
config = load_config(ctx)
|
|
18
|
+
if config is None:
|
|
19
|
+
raise click.ClickException(f"No {CONFIG_FILE} here.")
|
|
20
|
+
asyncio.run(serve_daemon(config, name))
|
|
@@ -6,6 +6,7 @@ import click
|
|
|
6
6
|
|
|
7
7
|
from labnotes.command_line_group import COMMAND_LINE
|
|
8
8
|
from labnotes.create_experiment import create_experiment
|
|
9
|
+
from labnotes.daemon.command import command as daemon_command
|
|
9
10
|
from labnotes.runner.start_experiment import start_experiment
|
|
10
11
|
from labnotes.utils.format_bytes import format_bytes
|
|
11
12
|
from labnotes.load_config import load_config
|
|
@@ -43,7 +44,7 @@ class PassthroughCommand(click.Command):
|
|
|
43
44
|
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
44
45
|
)
|
|
45
46
|
@click.argument("name", required=False)
|
|
46
|
-
@click.argument("action", required=False, metavar="[runs|run]")
|
|
47
|
+
@click.argument("action", required=False, metavar="[runs|run|stop]")
|
|
47
48
|
@click.option(
|
|
48
49
|
"--format",
|
|
49
50
|
"output_format",
|
|
@@ -58,6 +59,11 @@ class PassthroughCommand(click.Command):
|
|
|
58
59
|
" .labnotes.toml; 1 if none sets it.",
|
|
59
60
|
)
|
|
60
61
|
@click.option("--run-id", help="With run: restart only this failed run by id.")
|
|
62
|
+
@click.option(
|
|
63
|
+
"--headless",
|
|
64
|
+
is_flag=True,
|
|
65
|
+
help="With run: keep the scheduler in this foreground process.",
|
|
66
|
+
)
|
|
61
67
|
@click.option(
|
|
62
68
|
"-e",
|
|
63
69
|
"--experiment-config",
|
|
@@ -116,6 +122,7 @@ def experiments(
|
|
|
116
122
|
output_format: str | None,
|
|
117
123
|
concurrency: int | None,
|
|
118
124
|
run_id: str | None,
|
|
125
|
+
headless: bool,
|
|
119
126
|
experiment_config: Path | None,
|
|
120
127
|
timeout: int | None,
|
|
121
128
|
setup: str | None,
|
|
@@ -141,6 +148,8 @@ def experiments(
|
|
|
141
148
|
for flag, value in given.items():
|
|
142
149
|
if value is not None:
|
|
143
150
|
raise click.UsageError(f"{flag} needs new.")
|
|
151
|
+
if headless and action != "run":
|
|
152
|
+
raise click.UsageError("--headless needs run.")
|
|
144
153
|
match name, action:
|
|
145
154
|
case "new", _:
|
|
146
155
|
try:
|
|
@@ -174,7 +183,7 @@ def experiments(
|
|
|
174
183
|
run_id,
|
|
175
184
|
)
|
|
176
185
|
try:
|
|
177
|
-
if sys.stdout.isatty() and sys.stderr.isatty():
|
|
186
|
+
if not headless and sys.stdout.isatty() and sys.stderr.isatty():
|
|
178
187
|
results = run_tui(start, name, config, experiment_config)
|
|
179
188
|
else:
|
|
180
189
|
results = run_headless(start)
|
|
@@ -190,6 +199,29 @@ def experiments(
|
|
|
190
199
|
passed = len(results) - failed - retried
|
|
191
200
|
click.echo(f"{passed} passed, {failed} failed.{again}")
|
|
192
201
|
return
|
|
202
|
+
case str(), "stop":
|
|
203
|
+
if (
|
|
204
|
+
output_format is not None
|
|
205
|
+
or concurrency is not None
|
|
206
|
+
or run_id is not None
|
|
207
|
+
or experiment_config is not None
|
|
208
|
+
or timeout is not None
|
|
209
|
+
or setup is not None
|
|
210
|
+
or teardown is not None
|
|
211
|
+
or passthrough is not None
|
|
212
|
+
or ctx.args
|
|
213
|
+
):
|
|
214
|
+
raise click.UsageError("stop takes no run or listing options.")
|
|
215
|
+
try:
|
|
216
|
+
reply = daemon_command(config, name, {"command": "stop_all"})
|
|
217
|
+
except ValueError as error:
|
|
218
|
+
raise click.ClickException(str(error)) from error
|
|
219
|
+
if not reply.get("ok"):
|
|
220
|
+
raise click.ClickException(
|
|
221
|
+
str(reply.get("error", "Daemon rejected stop"))
|
|
222
|
+
)
|
|
223
|
+
click.echo(f"Asked daemon for {name} to stop all runs.")
|
|
224
|
+
return
|
|
193
225
|
if passthrough is not None:
|
|
194
226
|
raise click.UsageError("-- ARGS needs run.")
|
|
195
227
|
if run_id is not None:
|
|
@@ -197,7 +229,7 @@ def experiments(
|
|
|
197
229
|
if ctx.args:
|
|
198
230
|
raise click.UsageError(f"Unexpected {ctx.args[0]}. --PARAM=VALUE needs run.")
|
|
199
231
|
if action not in (None, "runs"):
|
|
200
|
-
raise click.UsageError(f"Unknown action {action}. Use runs or
|
|
232
|
+
raise click.UsageError(f"Unknown action {action}. Use runs, run or stop.")
|
|
201
233
|
if output_format is None and sys.stdout.isatty() and sys.stderr.isatty():
|
|
202
234
|
try:
|
|
203
235
|
browse_tui(config, name)
|
|
@@ -5,24 +5,35 @@ from dirsql import DirSQL
|
|
|
5
5
|
|
|
6
6
|
from labnotes.check_counts import check_counts
|
|
7
7
|
from labnotes.count_runs import count_runs
|
|
8
|
+
from labnotes.daemon.marker import daemon_marker
|
|
8
9
|
from labnotes.experiment_runs import experiment_runs
|
|
10
|
+
from labnotes.models import LabnotesConfig
|
|
9
11
|
from labnotes.summarize_config import summarize_config
|
|
10
12
|
|
|
11
13
|
|
|
12
|
-
async def list_experiments(
|
|
14
|
+
async def list_experiments(
|
|
15
|
+
tables: DirSQL, config: LabnotesConfig | None = None
|
|
16
|
+
) -> list[dict[str, object]]:
|
|
13
17
|
rows = await tables.query(
|
|
14
18
|
"SELECT name, display_name, config FROM experiments ORDER BY name"
|
|
15
19
|
)
|
|
16
20
|
listed = []
|
|
17
21
|
for row in rows:
|
|
18
22
|
name = str(row["name"])
|
|
19
|
-
|
|
20
|
-
summary = summarize_config(
|
|
23
|
+
definition = None if row["config"] is None else json.loads(str(row["config"]))
|
|
24
|
+
summary = summarize_config(definition)
|
|
21
25
|
varies = cast(dict[str, list[object]], summary["varies"])
|
|
22
26
|
source = experiment_runs(name, list(varies))
|
|
23
27
|
summary = await check_counts(tables, source, summary)
|
|
24
28
|
[totals] = await count_runs(tables, source, str(summary["completed_run"]))
|
|
29
|
+
marker = await daemon_marker(tables, config, name) if config is not None else {}
|
|
25
30
|
listed.append(
|
|
26
|
-
{
|
|
31
|
+
{
|
|
32
|
+
"name": name,
|
|
33
|
+
"display_name": row["display_name"],
|
|
34
|
+
**summary,
|
|
35
|
+
**totals,
|
|
36
|
+
**marker,
|
|
37
|
+
}
|
|
27
38
|
)
|
|
28
39
|
return listed
|
|
@@ -45,6 +45,8 @@ def describe_event(event: dict[str, object]) -> str:
|
|
|
45
45
|
return "Tearing down."
|
|
46
46
|
case "teardown failed":
|
|
47
47
|
return str(event["error"])
|
|
48
|
+
case "daemon error":
|
|
49
|
+
return str(event["error"])
|
|
48
50
|
case "stopping":
|
|
49
51
|
return "Stopping runs. Ctrl-C again to kill them."
|
|
50
52
|
case _:
|