labnotes 0.2.16__tar.gz → 0.2.18__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.16 → labnotes-0.2.18}/PKG-INFO +1 -1
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/cli.py +2 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/create_app.py +1 -1
- labnotes-0.2.18/src/labnotes/daemon/__init__.py +3 -0
- labnotes-0.2.18/src/labnotes/daemon/client.py +35 -0
- labnotes-0.2.18/src/labnotes/daemon/command.py +32 -0
- labnotes-0.2.18/src/labnotes/daemon/ensure.py +46 -0
- labnotes-0.2.18/src/labnotes/daemon/marker.py +27 -0
- labnotes-0.2.18/src/labnotes/daemon/server.py +237 -0
- labnotes-0.2.18/src/labnotes/daemon/socket_path.py +51 -0
- labnotes-0.2.18/src/labnotes/daemon_command.py +20 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/experiments.py +85 -18
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/list_experiments.py +15 -4
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/describe_event.py +2 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/experiment_rows.py +6 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/index.html +1 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/experiments_screen.py +10 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/leave_prompt.py +18 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_app.py +129 -8
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_tui.py +9 -2
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/runs_browser.py +14 -20
- {labnotes-0.2.16 → labnotes-0.2.18}/testing-conventions.toml +43 -2
- {labnotes-0.2.16 → labnotes-0.2.18}/.gitignore +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/CHANGELOG.md +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/MIGRATIONS.md +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/README.md +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/conftest.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/pyproject.toml +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/__init__.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/__main__.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/command_line_group.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/compose_experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/config_migration.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/context.md +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/copy_experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/count_groups.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/create_note.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/data_html.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/experiment_definition.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/file_changes.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/format_started.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/format_value.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/get_state.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/grid_cell.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/group_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/init.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/invocation_started.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/is_non_negative_int.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/is_non_negative_number.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/latest_log.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/load_config.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/load_experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/load_run.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/log.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/log_path.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/memory_interval.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/models.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/nests_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/note_path.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/output_html.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/param_names.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/parse_param_value.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/parse_params.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/parse_subset.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/pivot_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/read_config.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/read_path.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/retries.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/run_columns.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/run_output.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/__init__.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/cgroup_memory.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/check_args.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/check_hook_placeholders.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/check_subset.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/claim_run.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/command_flags.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/container_cgroup.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/container_memory.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/continue_run.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/docker_output.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/execute_run.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/exit_signal.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/fill_dirs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/fill_experiment_env.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/finalize_lost_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/first_invocation.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/flag_name.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/group_args.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/hold_experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/in_subset.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/invocation_hook.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/invocation_meta.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/override_flags.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/placeholders.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/prepare_experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/project_snapshot.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/read_experiment_config.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/record_memory.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_git.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_locked.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/run_state.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/signal_tree.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/start_experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/untracked_diff.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/wait_for_tree.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/runs_view_command.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/save_conflict_copy.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/save_note.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/server.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/set_state.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/shared_params.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/state_path.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/elapsed.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/experiment.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/labnotes.css +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/local-time.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/new-note.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/note-edit.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/note-editor.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/run-output.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/__init__.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/experiment_run_ids.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/experiment_toml.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/experiments_ddl.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/file_text.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/list_resumable_runs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/note_text.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/open_tables.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_blocks.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_log.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_memory.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/read_run_file.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/runs_columns.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/editor_imports.html +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/experiment.html +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/note.html +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/templates/run.html +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/browse_app.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/browse_tui.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/check_view_response.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/config.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/experiment_screen.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/fill_table.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/format_cell.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/guard_view.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/load_view.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/log_block.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/log_blocks.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/native_rows.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/reload_on_change.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_cells.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_output.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/run_panel.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/runs_view.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/stopped_title.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/table_columns.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/table_rows.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/view_column.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/view_pane.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/view_process.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/tui/view_switcher.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/__init__.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/format_bytes.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/process_alive.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/run_usage.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/tree_rss.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/utils/tree_usage.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/__init__.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/answer.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/build.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/changed.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/experiment.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/respond.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view/serve.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/view_specs.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/wait_for_note.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/src/labnotes/watch_rows.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/tests/conftest.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/tests/e2e/conftest.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/tests/integration/conftest.py +0 -0
- {labnotes-0.2.16 → labnotes-0.2.18}/uv.lock +0 -0
|
@@ -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,7 +6,10 @@ 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
|
|
10
|
+
from labnotes.daemon.ensure import ensure_daemon
|
|
9
11
|
from labnotes.runner.start_experiment import start_experiment
|
|
12
|
+
from labnotes.runner.run_state import RunState
|
|
10
13
|
from labnotes.utils.format_bytes import format_bytes
|
|
11
14
|
from labnotes.load_config import load_config
|
|
12
15
|
from labnotes.parse_params import parse_params
|
|
@@ -43,7 +46,7 @@ class PassthroughCommand(click.Command):
|
|
|
43
46
|
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
44
47
|
)
|
|
45
48
|
@click.argument("name", required=False)
|
|
46
|
-
@click.argument("action", required=False, metavar="[runs|run]")
|
|
49
|
+
@click.argument("action", required=False, metavar="[runs|run|stop]")
|
|
47
50
|
@click.option(
|
|
48
51
|
"--format",
|
|
49
52
|
"output_format",
|
|
@@ -58,6 +61,11 @@ class PassthroughCommand(click.Command):
|
|
|
58
61
|
" .labnotes.toml; 1 if none sets it.",
|
|
59
62
|
)
|
|
60
63
|
@click.option("--run-id", help="With run: restart only this failed run by id.")
|
|
64
|
+
@click.option(
|
|
65
|
+
"--headless",
|
|
66
|
+
is_flag=True,
|
|
67
|
+
help="With run: keep the scheduler in this foreground process.",
|
|
68
|
+
)
|
|
61
69
|
@click.option(
|
|
62
70
|
"-e",
|
|
63
71
|
"--experiment-config",
|
|
@@ -116,6 +124,7 @@ def experiments(
|
|
|
116
124
|
output_format: str | None,
|
|
117
125
|
concurrency: int | None,
|
|
118
126
|
run_id: str | None,
|
|
127
|
+
headless: bool,
|
|
119
128
|
experiment_config: Path | None,
|
|
120
129
|
timeout: int | None,
|
|
121
130
|
setup: str | None,
|
|
@@ -141,6 +150,8 @@ def experiments(
|
|
|
141
150
|
for flag, value in given.items():
|
|
142
151
|
if value is not None:
|
|
143
152
|
raise click.UsageError(f"{flag} needs new.")
|
|
153
|
+
if headless and action != "run":
|
|
154
|
+
raise click.UsageError("--headless needs run.")
|
|
144
155
|
match name, action:
|
|
145
156
|
case "new", _:
|
|
146
157
|
try:
|
|
@@ -160,24 +171,57 @@ def experiments(
|
|
|
160
171
|
only = parse_subset(ctx.args)
|
|
161
172
|
except ValueError as error:
|
|
162
173
|
raise click.UsageError(str(error)) from error
|
|
163
|
-
start = start_experiment(
|
|
164
|
-
config,
|
|
165
|
-
name,
|
|
166
|
-
experiment_config,
|
|
167
|
-
concurrency,
|
|
168
|
-
timeout,
|
|
169
|
-
only,
|
|
170
|
-
passthrough or None,
|
|
171
|
-
setup,
|
|
172
|
-
teardown,
|
|
173
|
-
ctx.meta.get(COMMAND_LINE),
|
|
174
|
-
run_id,
|
|
175
|
-
)
|
|
176
174
|
try:
|
|
177
|
-
if sys.stdout.isatty() and sys.stderr.isatty():
|
|
178
|
-
|
|
175
|
+
if not headless and sys.stdout.isatty() and sys.stderr.isatty():
|
|
176
|
+
asyncio.run(ensure_daemon(config, name))
|
|
177
|
+
reply = daemon_command(
|
|
178
|
+
config,
|
|
179
|
+
name,
|
|
180
|
+
{
|
|
181
|
+
"command": "restart" if run_id else "start",
|
|
182
|
+
"options": {
|
|
183
|
+
"experiment_config": str(experiment_config)
|
|
184
|
+
if experiment_config
|
|
185
|
+
else None,
|
|
186
|
+
"concurrency": concurrency,
|
|
187
|
+
"timeout": timeout,
|
|
188
|
+
"only": only,
|
|
189
|
+
"args": passthrough or None,
|
|
190
|
+
"setup": setup,
|
|
191
|
+
"teardown": teardown,
|
|
192
|
+
"command_line": ctx.meta.get(COMMAND_LINE),
|
|
193
|
+
"run_id": run_id,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
if not reply.get("ok"):
|
|
198
|
+
raise ValueError(
|
|
199
|
+
str(reply.get("error", "Daemon rejected start"))
|
|
200
|
+
)
|
|
201
|
+
run_tui(
|
|
202
|
+
None,
|
|
203
|
+
name,
|
|
204
|
+
config,
|
|
205
|
+
experiment_config,
|
|
206
|
+
daemon_journal=RunState(Path(str(reply["journal"]))),
|
|
207
|
+
)
|
|
208
|
+
return
|
|
179
209
|
else:
|
|
180
|
-
results = run_headless(
|
|
210
|
+
results = run_headless(
|
|
211
|
+
start_experiment(
|
|
212
|
+
config,
|
|
213
|
+
name,
|
|
214
|
+
experiment_config,
|
|
215
|
+
concurrency,
|
|
216
|
+
timeout,
|
|
217
|
+
only,
|
|
218
|
+
passthrough or None,
|
|
219
|
+
setup,
|
|
220
|
+
teardown,
|
|
221
|
+
ctx.meta.get(COMMAND_LINE),
|
|
222
|
+
run_id,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
181
225
|
except ValueError as error:
|
|
182
226
|
raise click.ClickException(str(error)) from error
|
|
183
227
|
superseded = {r["retry_of"] for r in results if "retry_of" in r}
|
|
@@ -190,6 +234,29 @@ def experiments(
|
|
|
190
234
|
passed = len(results) - failed - retried
|
|
191
235
|
click.echo(f"{passed} passed, {failed} failed.{again}")
|
|
192
236
|
return
|
|
237
|
+
case str(), "stop":
|
|
238
|
+
if (
|
|
239
|
+
output_format is not None
|
|
240
|
+
or concurrency is not None
|
|
241
|
+
or run_id is not None
|
|
242
|
+
or experiment_config is not None
|
|
243
|
+
or timeout is not None
|
|
244
|
+
or setup is not None
|
|
245
|
+
or teardown is not None
|
|
246
|
+
or passthrough is not None
|
|
247
|
+
or ctx.args
|
|
248
|
+
):
|
|
249
|
+
raise click.UsageError("stop takes no run or listing options.")
|
|
250
|
+
try:
|
|
251
|
+
reply = daemon_command(config, name, {"command": "stop_all"})
|
|
252
|
+
except ValueError as error:
|
|
253
|
+
raise click.ClickException(str(error)) from error
|
|
254
|
+
if not reply.get("ok"):
|
|
255
|
+
raise click.ClickException(
|
|
256
|
+
str(reply.get("error", "Daemon rejected stop"))
|
|
257
|
+
)
|
|
258
|
+
click.echo(f"Asked daemon for {name} to stop all runs.")
|
|
259
|
+
return
|
|
193
260
|
if passthrough is not None:
|
|
194
261
|
raise click.UsageError("-- ARGS needs run.")
|
|
195
262
|
if run_id is not None:
|
|
@@ -197,7 +264,7 @@ def experiments(
|
|
|
197
264
|
if ctx.args:
|
|
198
265
|
raise click.UsageError(f"Unexpected {ctx.args[0]}. --PARAM=VALUE needs run.")
|
|
199
266
|
if action not in (None, "runs"):
|
|
200
|
-
raise click.UsageError(f"Unknown action {action}. Use runs or
|
|
267
|
+
raise click.UsageError(f"Unknown action {action}. Use runs, run or stop.")
|
|
201
268
|
if output_format is None and sys.stdout.isatty() and sys.stderr.isatty():
|
|
202
269
|
try:
|
|
203
270
|
browse_tui(config, name)
|