labnotes 0.0.103__tar.gz → 0.0.104__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.103 → labnotes-0.0.104}/PKG-INFO +1 -1
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/context.md +17 -2
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/experiments.py +9 -2
- labnotes-0.0.104/src/labnotes/is_non_negative_int.py +2 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/models.py +3 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/read_config.py +6 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/read_path.py +1 -1
- labnotes-0.0.104/src/labnotes/retries.py +4 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/describe_event.py +11 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/execute_run.py +3 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/prepare_experiment.py +1 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/read_experiment_config.py +18 -2
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/run_experiment.py +58 -5
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_app.py +31 -4
- {labnotes-0.0.103 → labnotes-0.0.104}/testing-conventions.toml +6 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/.gitignore +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/CHANGELOG.md +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/MIGRATIONS.md +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/README.md +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/pyproject.toml +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/__main__.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/cli.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/compose_experiment.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/count_groups.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/create_app.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/create_note.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/__init__.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/control_run.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/daemon_app.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/daemon_client.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/daemon_running.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/daemon_server.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/describe_status.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/ensure_daemon.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/exit_when_idle.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/remote_start.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/run_daemon.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/run_logs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/serve_daemon.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/socket_path.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/stop_runs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/watch_daemon.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/watch_status.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon_command.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/file_changes.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/format_started.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/group_runs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/init.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/invocation_started.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/load_config.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/load_experiment.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/load_run.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/log.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/log_path.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/modes.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/nests_runs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/note_path.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/run_command.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/run_control.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/run_output.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/run_status.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/__init__.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/hold_folder.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/new_param_run_id.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/param_run_name.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/signal_tree.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/wait_for_tree.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/save_conflict_copy.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/save_note.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/server.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/shared_params.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/elapsed.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/experiment.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/labnotes.css +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/local-time.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/new-note.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/note-edit.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/note-editor.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/run-output.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/__init__.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/experiment_rows.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/experiment_run_ids.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/experiment_toml.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/experiments_ddl.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/file_text.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/note_text.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/open_tables.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_blocks.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_log.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/editor_imports.html +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/experiment.html +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/note.html +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/run.html +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/browse_app.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/browse_tui.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/experiment_screen.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/experiments_screen.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/fill_table.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/group_cells.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/invocation_columns.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/invocation_rows.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/leave_prompt.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/log_block.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/log_blocks.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/reload_on_change.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_cells.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_output.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_screen.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/stopped_title.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/wait_for_note.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/watch_rows.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/tests/integration/conftest.py +0 -0
- {labnotes-0.0.103 → labnotes-0.0.104}/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.104
|
|
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>
|
|
@@ -92,14 +92,28 @@ A script run by labnotes should:
|
|
|
92
92
|
- take each param as a `--name value` flag, parsing lists and tables from JSON;
|
|
93
93
|
- take its output folder as an argument, passed in `command` as `{run_dir}`, and write everything it produces there;
|
|
94
94
|
- exit non-zero when it fails, so the run counts as failed and runs again next time;
|
|
95
|
+
- exit 75 (`EX_TEMPFAIL`) only when the run couldn't do its work for a reason outside the experiment, like no Docker network or no capacity, so labnotes retries it (see below). labnotes never retries any other exit on its own;
|
|
95
96
|
- stop any background process it starts before it exits; the run ends when `command` exits.
|
|
96
97
|
- flush its output as it goes (`print(..., flush=True)`, or `PYTHONUNBUFFERED=1`), so the live view shows it as it happens.
|
|
97
98
|
|
|
99
|
+
A run that exits 75 is retried on its own, with the same params, in a new run
|
|
100
|
+
folder whose `run.json` holds `"retry_of": "<run id>"`. The first retry waits 30
|
|
101
|
+
seconds, and each one after waits twice as long. `retries` in `experiment.toml`
|
|
102
|
+
sets how many (0 turns them off), then `retries` under `[experiments]` in
|
|
103
|
+
`.labnotes.toml`, then 2. The failed run keeps its folder, logs and exit code,
|
|
104
|
+
and the default `counts` rule skips it, so a retry fills its place rather than
|
|
105
|
+
adding a repeat. When the retries run out, the last run keeps exit 75: it never
|
|
106
|
+
got to test its params, so treat it as blocked, not as a result. Stopping the
|
|
107
|
+
runs, or `x` on a run waiting to retry, cancels the retry. A run that was stopped never retries. The live view
|
|
108
|
+
shows a waiting retry as `retry in 30s` and the end as `gave up (exit 75)`;
|
|
109
|
+
`run status --json` reports `retrying`, `retried`, `gave up` and
|
|
110
|
+
`retry cancelled` events.
|
|
111
|
+
|
|
98
112
|
Runs can take a long time. Start `run` in the background, then check on it with `labnotes experiments foo runs --format json`; a run with no `ended` is still going.
|
|
99
113
|
|
|
100
114
|
In a terminal, `run` shows a live table of runs (status and time) and stays
|
|
101
115
|
open when they finish. Failed runs show their status in red; `f` hides or shows them, and `r` on one
|
|
102
|
-
runs it again under a new id. `j` and `k` move the cursor, `g` and `G` jump to the
|
|
116
|
+
runs it again under a new id, with `retry_of` set. `j` and `k` move the cursor, `g` and `G` jump to the
|
|
103
117
|
first and last row. Runs not started yet show grayed as `pending`, and as
|
|
104
118
|
`skipped` once stopped. Move the cursor to a pending run and press `n` to run it
|
|
105
119
|
next. `m` grabs a pending run; `j` and `k` move it through the queue, Enter or `m`
|
|
@@ -140,7 +154,8 @@ going. Elsewhere, it prints a line per event instead.
|
|
|
140
154
|
run to a place in the queue counted from 0,
|
|
141
155
|
`set_concurrency` to a number, `interrupt` or `finish`.
|
|
142
156
|
- A second `run` of the same experiment fails while the first is going.
|
|
143
|
-
- It exits 0 even if runs fail; the summary line counts failures.
|
|
157
|
+
- It exits 0 even if runs fail; the summary line counts failures. A run that
|
|
158
|
+
was retried counts as retried, not failed. If it can't
|
|
144
159
|
start or reach the daemon, it prints the cause on one line and exits 1.
|
|
145
160
|
|
|
146
161
|
`uvx labnotes server` serves the experiments, their markdown files and their runs as a local site, read from the same `.labnotes.toml`. An experiment's page shows its `experiment.toml` on one line, then a Runs tab with a table of its runs and a Notebooks tab with one tab per markdown file. Clicking a run opens its `stdout.log` and `stderr.log` together, followed as they're written until the run ends.
|
|
@@ -79,8 +79,15 @@ def experiments(
|
|
|
79
79
|
results = run_headless(start)
|
|
80
80
|
except ValueError as error:
|
|
81
81
|
raise click.ClickException(str(error)) from error
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
superseded = {r["retry_of"] for r in results if "retry_of" in r}
|
|
83
|
+
retried = sum(result.get("run") in superseded for result in results)
|
|
84
|
+
failed = sum(
|
|
85
|
+
result["exit_code"] != 0 and result.get("run") not in superseded
|
|
86
|
+
for result in results
|
|
87
|
+
)
|
|
88
|
+
again = f" {retried} retried." if retried else ""
|
|
89
|
+
passed = len(results) - failed - retried
|
|
90
|
+
click.echo(f"{passed} passed, {failed} failed.{again}")
|
|
84
91
|
return
|
|
85
92
|
if action not in (None, "runs"):
|
|
86
93
|
raise click.UsageError(f"Unknown action {action}. Use runs or run.")
|
|
@@ -5,6 +5,7 @@ from typing import Any, Protocol
|
|
|
5
5
|
|
|
6
6
|
from labnotes.default_counts import DEFAULT_COUNTS
|
|
7
7
|
from labnotes.modes import DEFAULT_MODE
|
|
8
|
+
from labnotes.retries import DEFAULT_RETRIES
|
|
8
9
|
|
|
9
10
|
Report = Callable[[dict[str, object]], None]
|
|
10
11
|
|
|
@@ -47,6 +48,7 @@ class LabnotesConfig:
|
|
|
47
48
|
runs: str
|
|
48
49
|
mode: str = DEFAULT_MODE
|
|
49
50
|
concurrency: int = 1
|
|
51
|
+
retries: int = DEFAULT_RETRIES
|
|
50
52
|
# Set when the config's folder is itself the one experiment.
|
|
51
53
|
experiment: str | None = None
|
|
52
54
|
# [experiment] in .labnotes.toml, under each experiment.toml.
|
|
@@ -62,3 +64,4 @@ class ExperimentConfig:
|
|
|
62
64
|
counts: str = DEFAULT_COUNTS
|
|
63
65
|
mode: str = DEFAULT_MODE
|
|
64
66
|
concurrency: int = 1
|
|
67
|
+
retries: int = DEFAULT_RETRIES
|
|
@@ -2,10 +2,12 @@ import tomllib
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
from labnotes.models import LabnotesConfig
|
|
5
|
+
from labnotes.is_non_negative_int import is_non_negative_int
|
|
5
6
|
from labnotes.is_positive_int import is_positive_int
|
|
6
7
|
from labnotes.modes import DEFAULT_MODE, MODES
|
|
7
8
|
from labnotes.runner.read_experiment_config import SETTINGS
|
|
8
9
|
from labnotes.read_path import DEFAULT_PATHS, read_path
|
|
10
|
+
from labnotes.retries import DEFAULT_RETRIES
|
|
9
11
|
|
|
10
12
|
CONFIG_FILE = ".labnotes.toml"
|
|
11
13
|
|
|
@@ -45,12 +47,16 @@ def read_config(path: Path) -> LabnotesConfig:
|
|
|
45
47
|
raise ValueError(
|
|
46
48
|
f"{path}: [experiments] concurrency must be a positive integer"
|
|
47
49
|
)
|
|
50
|
+
retries = experiments.get("retries", DEFAULT_RETRIES)
|
|
51
|
+
if not is_non_negative_int(retries):
|
|
52
|
+
raise ValueError(f"{path}: [experiments] retries must be 0 or more")
|
|
48
53
|
return LabnotesConfig(
|
|
49
54
|
path.resolve(),
|
|
50
55
|
root,
|
|
51
56
|
**paths,
|
|
52
57
|
mode=mode,
|
|
53
58
|
concurrency=concurrency,
|
|
59
|
+
retries=retries,
|
|
54
60
|
experiment=root.name if single else None,
|
|
55
61
|
defaults=defaults,
|
|
56
62
|
)
|
|
@@ -16,7 +16,7 @@ PLACEHOLDERS = {
|
|
|
16
16
|
# experiments is missing: one experiment has no folder to point at.
|
|
17
17
|
SINGLE_PLACEHOLDERS: dict[str, set[str]] = {"files": set(), "runs": {"run"}}
|
|
18
18
|
# Keys other readers own, so the path check lets them through.
|
|
19
|
-
OTHER_KEYS = {"experiments": {"mode", "concurrency"}}
|
|
19
|
+
OTHER_KEYS = {"experiments": {"mode", "concurrency", "retries"}}
|
|
20
20
|
PLACEHOLDER = re.compile(r"\{([^}]*)\}")
|
|
21
21
|
SINGLE = "experiment.toml sits next to .labnotes.toml, so this folder is the experiment"
|
|
22
22
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
from labnotes.retries import RETRY_EXIT_CODE
|
|
1
2
|
from labnotes.runner.param_args import param_args
|
|
3
|
+
from labnotes.tui.format_elapsed import format_elapsed
|
|
2
4
|
|
|
3
5
|
|
|
4
6
|
def describe_event(event: dict[str, object]) -> str:
|
|
@@ -28,6 +30,15 @@ def describe_event(event: dict[str, object]) -> str:
|
|
|
28
30
|
return f"Killing {event['run']}."
|
|
29
31
|
case "retried":
|
|
30
32
|
return f"{event['run']} retried as {event['retry']}."
|
|
33
|
+
case "retrying":
|
|
34
|
+
wait = format_elapsed(event["seconds"]) # type: ignore[arg-type]
|
|
35
|
+
attempt = f"{event['attempt']} of {event['retries']}"
|
|
36
|
+
return f"{event['run']} retries in {wait} (retry {attempt})."
|
|
37
|
+
case "gave up":
|
|
38
|
+
tried = f"after {event['retries']} retries"
|
|
39
|
+
return f"{event['run']} gave up (exit {RETRY_EXIT_CODE}) {tried}."
|
|
40
|
+
case "retry cancelled":
|
|
41
|
+
return f"{event['run']} won't retry."
|
|
31
42
|
case "idle":
|
|
32
43
|
return "No runs left."
|
|
33
44
|
case "concurrency":
|
|
@@ -23,6 +23,7 @@ async def execute_run(
|
|
|
23
23
|
running: set[asyncio.subprocess.Process],
|
|
24
24
|
stopping: asyncio.Event,
|
|
25
25
|
tree: set[psutil.Process],
|
|
26
|
+
retry_of: str | None = None,
|
|
26
27
|
timeout: float | None = None,
|
|
27
28
|
stop: Callable[[], None] = lambda: None,
|
|
28
29
|
) -> dict[str, object]:
|
|
@@ -33,6 +34,8 @@ async def execute_run(
|
|
|
33
34
|
"exit_code": None,
|
|
34
35
|
"error": None,
|
|
35
36
|
}
|
|
37
|
+
if retry_of is not None:
|
|
38
|
+
run["retry_of"] = retry_of
|
|
36
39
|
write_run_json(folder, run)
|
|
37
40
|
with (
|
|
38
41
|
(folder / "stdout.log").open("wb") as stdout,
|
|
@@ -6,10 +6,20 @@ from typing import Any
|
|
|
6
6
|
from labnotes.compose_experiment import compose_experiment
|
|
7
7
|
from labnotes.default_counts import DEFAULT_COUNTS
|
|
8
8
|
from labnotes.models import ExperimentConfig
|
|
9
|
+
from labnotes.is_non_negative_int import is_non_negative_int
|
|
9
10
|
from labnotes.is_positive_int import is_positive_int
|
|
10
11
|
from labnotes.modes import MODES
|
|
11
12
|
|
|
12
|
-
SETTINGS = {
|
|
13
|
+
SETTINGS = {
|
|
14
|
+
"name",
|
|
15
|
+
"command",
|
|
16
|
+
"repeats",
|
|
17
|
+
"params",
|
|
18
|
+
"counts",
|
|
19
|
+
"mode",
|
|
20
|
+
"concurrency",
|
|
21
|
+
"retries",
|
|
22
|
+
}
|
|
13
23
|
|
|
14
24
|
|
|
15
25
|
def read_experiment_config(
|
|
@@ -17,6 +27,7 @@ def read_experiment_config(
|
|
|
17
27
|
source: str,
|
|
18
28
|
mode: str,
|
|
19
29
|
concurrency: int,
|
|
30
|
+
retries: int,
|
|
20
31
|
defaults: dict[str, object] | None = None,
|
|
21
32
|
) -> ExperimentConfig:
|
|
22
33
|
try:
|
|
@@ -55,4 +66,9 @@ def read_experiment_config(
|
|
|
55
66
|
concurrency = values.get("concurrency", concurrency)
|
|
56
67
|
if not is_positive_int(concurrency):
|
|
57
68
|
raise ValueError(f"{path}: concurrency must be a positive integer")
|
|
58
|
-
|
|
69
|
+
retries = values.get("retries", retries)
|
|
70
|
+
if not is_non_negative_int(retries):
|
|
71
|
+
raise ValueError(f"{path}: retries must be 0 or more")
|
|
72
|
+
return ExperimentConfig(
|
|
73
|
+
command, repeats, params, name, counts, mode, concurrency, retries
|
|
74
|
+
)
|
|
@@ -11,6 +11,7 @@ from labnotes.models import (
|
|
|
11
11
|
Listen,
|
|
12
12
|
Report,
|
|
13
13
|
)
|
|
14
|
+
from labnotes.retries import RETRY_EXIT_CODE, RETRY_SECONDS
|
|
14
15
|
from labnotes.runner.execute_run import execute_run
|
|
15
16
|
from labnotes.runner.new_param_run_id import new_param_run_id
|
|
16
17
|
from labnotes.runner.new_stamped_run_id import new_stamped_run_id
|
|
@@ -33,6 +34,7 @@ async def run_experiment(
|
|
|
33
34
|
report: Report,
|
|
34
35
|
listen: Listen,
|
|
35
36
|
timeout: float | None = None,
|
|
37
|
+
retry_seconds: float = RETRY_SECONDS,
|
|
36
38
|
) -> list[dict[str, object]]:
|
|
37
39
|
tables = open_tables(config)
|
|
38
40
|
existing = await experiment_run_ids(tables, name)
|
|
@@ -94,11 +96,19 @@ async def run_experiment(
|
|
|
94
96
|
stopping.set()
|
|
95
97
|
report({"event": "stopping"})
|
|
96
98
|
signal_runs(signal.SIGTERM)
|
|
99
|
+
if waiting:
|
|
100
|
+
for run in list(waiting):
|
|
101
|
+
cancel_retry(run)
|
|
102
|
+
fill()
|
|
97
103
|
elif any(running.values()):
|
|
98
104
|
report({"event": "killing"})
|
|
99
105
|
signal_runs(signal.SIGKILL)
|
|
100
106
|
|
|
101
107
|
def kill(run: str) -> None:
|
|
108
|
+
if run in waiting:
|
|
109
|
+
cancel_retry(run)
|
|
110
|
+
fill()
|
|
111
|
+
return
|
|
102
112
|
if not running.get(run):
|
|
103
113
|
return
|
|
104
114
|
if run in stopped:
|
|
@@ -147,16 +157,54 @@ async def run_experiment(
|
|
|
147
157
|
fill()
|
|
148
158
|
|
|
149
159
|
ended: dict[str, dict[str, object]] = {}
|
|
160
|
+
parents: dict[str, str] = {}
|
|
161
|
+
attempts: dict[str, int] = {}
|
|
162
|
+
waiting: dict[str, asyncio.TimerHandle] = {}
|
|
150
163
|
|
|
151
|
-
def
|
|
152
|
-
if stopping.is_set() or run not in ended:
|
|
153
|
-
return
|
|
164
|
+
def requeue(run: str, attempt: int) -> None:
|
|
154
165
|
again = new_id(ended[run])
|
|
155
166
|
planned.add(again)
|
|
167
|
+
parents[again] = run
|
|
168
|
+
attempts[again] = attempt
|
|
156
169
|
plan.append((again, ended[run]))
|
|
157
170
|
report({"event": "retried", "run": run, "retry": again, "params": ended[run]})
|
|
158
171
|
fill()
|
|
159
172
|
|
|
173
|
+
def retry(run: str) -> None:
|
|
174
|
+
if stopping.is_set() or run not in ended:
|
|
175
|
+
return
|
|
176
|
+
if run in waiting:
|
|
177
|
+
waiting.pop(run).cancel()
|
|
178
|
+
requeue(run, attempts.get(run, 0) + 1)
|
|
179
|
+
else:
|
|
180
|
+
requeue(run, 0)
|
|
181
|
+
|
|
182
|
+
def retry_later(run: str) -> None:
|
|
183
|
+
tried = attempts.get(run, 0)
|
|
184
|
+
if tried >= experiment.retries:
|
|
185
|
+
report({"event": "gave up", "run": run, "retries": tried})
|
|
186
|
+
return
|
|
187
|
+
seconds = retry_seconds * 2**tried
|
|
188
|
+
|
|
189
|
+
def due() -> None:
|
|
190
|
+
del waiting[run]
|
|
191
|
+
requeue(run, tried + 1)
|
|
192
|
+
|
|
193
|
+
waiting[run] = asyncio.get_running_loop().call_later(seconds, due)
|
|
194
|
+
report(
|
|
195
|
+
{
|
|
196
|
+
"event": "retrying",
|
|
197
|
+
"run": run,
|
|
198
|
+
"attempt": tried + 1,
|
|
199
|
+
"retries": experiment.retries,
|
|
200
|
+
"seconds": seconds,
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def cancel_retry(run: str) -> None:
|
|
205
|
+
waiting.pop(run).cancel()
|
|
206
|
+
report({"event": "retry cancelled", "run": run})
|
|
207
|
+
|
|
160
208
|
limit = concurrency
|
|
161
209
|
live: set[asyncio.Task[None]] = set()
|
|
162
210
|
started: list[asyncio.Task[None]] = []
|
|
@@ -169,7 +217,7 @@ async def run_experiment(
|
|
|
169
217
|
task.add_done_callback(end)
|
|
170
218
|
live.add(task)
|
|
171
219
|
started.append(task)
|
|
172
|
-
if not live:
|
|
220
|
+
if not (live or waiting):
|
|
173
221
|
report({"event": "idle"})
|
|
174
222
|
if finishing:
|
|
175
223
|
done.set()
|
|
@@ -177,7 +225,7 @@ async def run_experiment(
|
|
|
177
225
|
def finish() -> None:
|
|
178
226
|
nonlocal finishing
|
|
179
227
|
finishing = True
|
|
180
|
-
if not (live or plan):
|
|
228
|
+
if not (live or plan or waiting):
|
|
181
229
|
done.set()
|
|
182
230
|
|
|
183
231
|
def end(task: asyncio.Task[None]) -> None:
|
|
@@ -204,6 +252,7 @@ async def run_experiment(
|
|
|
204
252
|
running[run],
|
|
205
253
|
stopping,
|
|
206
254
|
trees[run],
|
|
255
|
+
parents.get(run),
|
|
207
256
|
timeout=timeout,
|
|
208
257
|
stop=lambda: kill(run),
|
|
209
258
|
)
|
|
@@ -211,6 +260,10 @@ async def run_experiment(
|
|
|
211
260
|
report({"event": "ended", "run": run, **result})
|
|
212
261
|
ended[run] = params
|
|
213
262
|
results.append(result)
|
|
263
|
+
if result["exit_code"] == RETRY_EXIT_CODE and not (
|
|
264
|
+
stopping.is_set() or run in stopped
|
|
265
|
+
):
|
|
266
|
+
retry_later(run)
|
|
214
267
|
|
|
215
268
|
listen(
|
|
216
269
|
Controls(
|
|
@@ -9,6 +9,7 @@ from textual.binding import Binding
|
|
|
9
9
|
from textual.widgets import DataTable, Footer, Header, Input, Static
|
|
10
10
|
|
|
11
11
|
from labnotes.models import Controls, Start
|
|
12
|
+
from labnotes.retries import RETRY_EXIT_CODE
|
|
12
13
|
from labnotes.runner.describe_event import describe_event
|
|
13
14
|
from labnotes.runner.stop_then_kill import GRACE_SECONDS, stop_then_kill
|
|
14
15
|
from labnotes.runner.param_args import param_args
|
|
@@ -75,6 +76,8 @@ class RunApp(App[None]):
|
|
|
75
76
|
self.cells: dict[str, list[object]] = {}
|
|
76
77
|
self.filter = ""
|
|
77
78
|
self.failed: set[str] = set()
|
|
79
|
+
self.retried: set[str] = set()
|
|
80
|
+
self.waiting: dict[str, float] = {}
|
|
78
81
|
self.hiding = False
|
|
79
82
|
self.folders: dict[str, Path] = {}
|
|
80
83
|
self.viewing: str | None = None
|
|
@@ -178,7 +181,24 @@ class RunApp(App[None]):
|
|
|
178
181
|
self.set_cell(run, "status", dim(PENDING))
|
|
179
182
|
self.reorder(event, table.cursor_row)
|
|
180
183
|
self.show(describe_event(event))
|
|
184
|
+
case "retrying":
|
|
185
|
+
self.waiting[run] = time.monotonic() + float(event["seconds"]) # type: ignore[arg-type]
|
|
186
|
+
self.tick()
|
|
187
|
+
self.show(describe_event(event))
|
|
188
|
+
case "gave up":
|
|
189
|
+
gave_up = f"{FAILED} gave up (exit {RETRY_EXIT_CODE})"
|
|
190
|
+
self.set_cell(run, "status", Text(gave_up, style="red"))
|
|
191
|
+
self.show(describe_event(event))
|
|
192
|
+
case "retry cancelled":
|
|
193
|
+
del self.waiting[run]
|
|
194
|
+
outcome = f"{FAILED} exited {RETRY_EXIT_CODE}"
|
|
195
|
+
self.set_cell(run, "status", Text(outcome, style="red"))
|
|
196
|
+
self.show(describe_event(event))
|
|
181
197
|
case "retried":
|
|
198
|
+
if self.waiting.pop(run, None) is not None:
|
|
199
|
+
outcome = f"{FAILED} exited {RETRY_EXIT_CODE}, retried"
|
|
200
|
+
self.set_cell(run, "status", Text(outcome, style="red"))
|
|
201
|
+
self.retried.add(run)
|
|
182
202
|
again = str(event["retry"])
|
|
183
203
|
args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
|
|
184
204
|
self.cells[again] = [
|
|
@@ -200,9 +220,11 @@ class RunApp(App[None]):
|
|
|
200
220
|
self.show(describe_event(event))
|
|
201
221
|
|
|
202
222
|
def show_summary(self) -> None:
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
self.
|
|
223
|
+
retried = len(self.retried)
|
|
224
|
+
failed = len(self.failed - self.retried)
|
|
225
|
+
passed = len(self.ended) - failed - retried
|
|
226
|
+
again = f" {retried} retried." if retried else ""
|
|
227
|
+
self.show(f"{passed} passed, {failed} failed.{again} Ctrl-C to quit.")
|
|
206
228
|
|
|
207
229
|
def reposition(self, event: dict[str, object], run: str) -> None:
|
|
208
230
|
table = self.query_one(DataTable)
|
|
@@ -246,8 +268,13 @@ class RunApp(App[None]):
|
|
|
246
268
|
self.query_one("#concurrency", Static).update(f"{self.concurrency} at a time")
|
|
247
269
|
|
|
248
270
|
def tick(self) -> None:
|
|
271
|
+
now = time.monotonic()
|
|
249
272
|
for run, began in self.started.items():
|
|
250
|
-
self.set_cell(run, "time", format_elapsed(
|
|
273
|
+
self.set_cell(run, "time", format_elapsed(now - began))
|
|
274
|
+
for run, due in self.waiting.items():
|
|
275
|
+
wait = format_elapsed(max(0, due - now))
|
|
276
|
+
outcome = f"{FAILED} exited {RETRY_EXIT_CODE}, retry in {wait}"
|
|
277
|
+
self.set_cell(run, "status", Text(outcome, style="yellow"))
|
|
251
278
|
|
|
252
279
|
def spin(self) -> None:
|
|
253
280
|
self.frame = (self.frame + 1) % len(SPINNER)
|
|
@@ -63,3 +63,9 @@ path = "src/labnotes/tui/experiments_screen.py"
|
|
|
63
63
|
rules = ["mutation"]
|
|
64
64
|
lines = [36]
|
|
65
65
|
reason = "WorkerState members are singletons, so is equals =="
|
|
66
|
+
|
|
67
|
+
[[python.exempt]]
|
|
68
|
+
path = "src/labnotes/runner/run_experiment.py"
|
|
69
|
+
rules = ["mutation"]
|
|
70
|
+
lines = [184, 263]
|
|
71
|
+
reason = "a retry is only scheduled below the budget and each one adds one, so tried never passes retries: >= equals ==; both sides are small ints, which CPython caches, so is equals =="
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|