labnotes 0.0.42__tar.gz → 0.0.44__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {labnotes-0.0.42 → labnotes-0.0.44}/PKG-INFO +1 -1
  2. {labnotes-0.0.42 → labnotes-0.0.44}/pyproject.toml +1 -0
  3. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/context.md +9 -6
  4. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/create_app.py +2 -1
  5. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/list_experiments.py +2 -2
  6. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/load_experiment.py +2 -1
  7. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/models.py +3 -1
  8. labnotes-0.0.44/src/labnotes/modes.py +2 -0
  9. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/read_config.py +8 -8
  10. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/read_path.py +3 -1
  11. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/read_experiment_config.py +3 -3
  12. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/start_experiment.py +1 -1
  13. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/summarize_config.py +2 -2
  14. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/experiment_rows.py +3 -1
  15. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tui/run_app.py +15 -6
  16. labnotes-0.0.44/src/labnotes/tui/runs_table.py +11 -0
  17. {labnotes-0.0.42 → labnotes-0.0.44}/uv.lock +27 -0
  18. {labnotes-0.0.42 → labnotes-0.0.44}/.gitignore +0 -0
  19. {labnotes-0.0.42 → labnotes-0.0.44}/CHANGELOG.md +0 -0
  20. {labnotes-0.0.42 → labnotes-0.0.44}/MIGRATIONS.md +0 -0
  21. {labnotes-0.0.42 → labnotes-0.0.44}/README.md +0 -0
  22. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/__init__.py +0 -0
  23. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/asset_url.py +0 -0
  24. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/cell_progress.py +0 -0
  25. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/check_counts.py +0 -0
  26. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/cli.py +0 -0
  27. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/context_guide.py +0 -0
  28. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/count_runs.py +0 -0
  29. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/create_experiment.py +0 -0
  30. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/data_html.py +0 -0
  31. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/default_config_text.py +0 -0
  32. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/default_counts.py +0 -0
  33. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/drain_watch.py +0 -0
  34. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/experiment_notes.py +0 -0
  35. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/experiment_progress.py +0 -0
  36. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/experiment_runs.py +0 -0
  37. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/experiments.py +0 -0
  38. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/format_value.py +0 -0
  39. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/init.py +0 -0
  40. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/list_runs.py +0 -0
  41. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/load_config.py +0 -0
  42. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/notebook_kernel.py +0 -0
  43. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/output_html.py +0 -0
  44. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/parse_run_view.py +0 -0
  45. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/pivot_cells.py +0 -0
  46. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/print_rows.py +0 -0
  47. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/python_blocks.py +0 -0
  48. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/render_markdown.py +0 -0
  49. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/__init__.py +0 -0
  50. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/describe_event.py +0 -0
  51. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/execute_run.py +0 -0
  52. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/hold_folder.py +0 -0
  53. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/new_invocation_id.py +0 -0
  54. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/new_run_id.py +0 -0
  55. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  56. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/param_args.py +0 -0
  57. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/param_grid.py +0 -0
  58. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/plan_runs.py +0 -0
  59. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/run_experiment.py +0 -0
  60. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/run_folder.py +0 -0
  61. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/run_headless.py +0 -0
  62. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/runs_parent.py +0 -0
  63. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/shell_command.py +0 -0
  64. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/stop_then_kill.py +0 -0
  65. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/wait_for_group.py +0 -0
  66. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/runner/write_run_json.py +0 -0
  67. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/saved_outputs.py +0 -0
  68. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/serve_app.py +0 -0
  69. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/server.py +0 -0
  70. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/sort_href.py +0 -0
  71. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/sql_quote.py +0 -0
  72. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/static/labnotes.css +0 -0
  73. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/static/row-link.js +0 -0
  74. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/static/run-cell.js +0 -0
  75. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/static/view-toggle.js +0 -0
  76. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/stop_idle_kernels.py +0 -0
  77. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/__init__.py +0 -0
  78. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/file_prefix.py +0 -0
  79. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/iso_time.py +0 -0
  80. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/list_experiment_runs.py +0 -0
  81. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/open_tables.py +0 -0
  82. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/path_glob.py +0 -0
  83. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/path_pattern.py +0 -0
  84. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/read_experiment.py +0 -0
  85. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/read_file.py +0 -0
  86. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/read_run.py +0 -0
  87. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tables/summarize_experiments.py +0 -0
  88. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/templates/base.html +0 -0
  89. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/templates/experiment.html +0 -0
  90. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/templates/index.html +0 -0
  91. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/templates/macros.html +0 -0
  92. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tui/__init__.py +0 -0
  93. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tui/dim.py +0 -0
  94. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tui/format_elapsed.py +0 -0
  95. {labnotes-0.0.42 → labnotes-0.0.44}/src/labnotes/tui/run_tui.py +0 -0
  96. {labnotes-0.0.42 → labnotes-0.0.44}/testing-conventions.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.42
3
+ Version: 0.0.44
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>
@@ -32,6 +32,7 @@ labnotes = "labnotes.cli:main"
32
32
 
33
33
  [dependency-groups]
34
34
  dev = [
35
+ "curtaincall>=0.3.0",
35
36
  "httpx2>=2.13.1",
36
37
  "mypy>=2.4.0",
37
38
  "pytest>=8.0",
@@ -59,7 +59,9 @@ renaming the experiment means editing `name`.
59
59
  Each combination of `[params]` runs `repeats` times (1 if omitted), round-robin, on every
60
60
  `run`, whatever is already on disk. That is `mode = "adhoc"`, the default.
61
61
  `mode = "fill"` counts what is there: only runs that count fill a combination,
62
- so running again fills in what failed or never ran.
62
+ so running again fills in what failed or never ran. `mode` under
63
+ `[experiments]` in `.labnotes.toml` sets it for every experiment that doesn't
64
+ set its own.
63
65
  Each run gets a new run folder, named for the UTC time it was planned (`20261020-000429-brave-fox`), so folder listings sort by start. `command` runs in a shell from the root, with
64
66
  `{run_dir}` replaced by the run folder's absolute path. Pass it to the
65
67
  script so it writes its outputs there.
@@ -90,13 +92,14 @@ A script run by labnotes should:
90
92
  Runs can take a long time. Start `run` in the background, then check on it with `labnotes experiments foo runs`; a run with no `ended` is still going.
91
93
 
92
94
  In a terminal, `run` shows a live table of runs (status and time) and stays
93
- open when they finish. Failed runs show their status in red; `h` hides or shows them, and `r` on one
94
- runs it again under a new id. Runs not started yet show grayed as `pending`, and as
95
+ open when they finish. Failed runs show their status in red; `f` hides or shows them, and `r` on one
96
+ runs it again under a new id. `j` and `k` move the cursor, `g` and `G` jump to the
97
+ first and last row. Runs not started yet show grayed as `pending`, and as
95
98
  `skipped` once stopped. Move the cursor to a pending run and press `n` to run it
96
- next. `d` deselects a pending run so it won't run; `d` again puts it back at
99
+ next. Space deselects a pending run so it won't run; Space again puts it back at
97
100
  the end of the queue. `/` filters rows by params text; Enter keeps the filter,
98
- Escape clears it. `D` deselects every pending run the filter shows. `k` stops the
99
- running run under the cursor; `k` again kills it. `+` and
101
+ Escape clears it. `D` deselects every pending run the filter shows. `x` stops the
102
+ running run under the cursor; `x` again kills it. `+` and
100
103
  `-` change how many run at once; lowering it lets running runs
101
104
  finish. Enter on a run follows its `stdout.log`; `e` switches to
102
105
  `stderr.log` and back, and Escape returns to the table. An `adhoc` experiment that
@@ -50,7 +50,7 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
50
50
  await kernel.stop()
51
51
 
52
52
  async def index(request: Request) -> Response:
53
- experiments = await list_experiments(request.app.state.tables)
53
+ experiments = await list_experiments(request.app.state.tables, config.mode)
54
54
  return templates.TemplateResponse(
55
55
  request,
56
56
  "index.html",
@@ -62,6 +62,7 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
62
62
  loaded = await load_experiment(
63
63
  request.app.state.tables,
64
64
  config.root,
65
+ config.mode,
65
66
  name,
66
67
  request.query_params,
67
68
  lambda note, content: saved_outputs(kernels.get((name, note)), content),
@@ -9,7 +9,7 @@ from labnotes.experiment_runs import experiment_runs
9
9
  from labnotes.summarize_config import summarize_config
10
10
 
11
11
 
12
- async def list_experiments(tables: DirSQL) -> list[dict[str, object]]:
12
+ async def list_experiments(tables: DirSQL, mode: str) -> list[dict[str, object]]:
13
13
  rows = await tables.query(
14
14
  "SELECT name, display_name, config FROM experiments ORDER BY name"
15
15
  )
@@ -17,7 +17,7 @@ async def list_experiments(tables: DirSQL) -> list[dict[str, object]]:
17
17
  for row in rows:
18
18
  name = str(row["name"])
19
19
  config = None if row["config"] is None else json.loads(str(row["config"]))
20
- summary = summarize_config(config)
20
+ summary = summarize_config(config, mode)
21
21
  varies = cast(dict[str, list[object]], summary["varies"])
22
22
  source = experiment_runs(name, list(varies))
23
23
  summary = await check_counts(tables, source, summary)
@@ -25,6 +25,7 @@ def _no_outputs(note: str, content: str) -> Sequence[str]:
25
25
  async def load_experiment(
26
26
  tables: DirSQL,
27
27
  root: Path,
28
+ mode: str,
28
29
  name: str,
29
30
  params: Mapping[str, str],
30
31
  outputs_for: Callable[[str, str], Sequence[str]] = _no_outputs,
@@ -37,7 +38,7 @@ async def load_experiment(
37
38
  return None
38
39
  [row] = found
39
40
  config = None if row["config"] is None else json.loads(str(row["config"]))
40
- summary = summarize_config(config)
41
+ summary = summarize_config(config, mode)
41
42
  varies = cast(dict[str, list[object]], summary["varies"])
42
43
  source = experiment_runs(name, list(varies))
43
44
  summary = await check_counts(tables, source, summary)
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from typing import Any
5
5
 
6
6
  from labnotes.default_counts import DEFAULT_COUNTS
7
+ from labnotes.modes import DEFAULT_MODE
7
8
 
8
9
  Report = Callable[[dict[str, object]], None]
9
10
 
@@ -38,6 +39,7 @@ class LabnotesConfig:
38
39
  experiments: str
39
40
  files: str
40
41
  runs: str
42
+ mode: str = DEFAULT_MODE
41
43
 
42
44
 
43
45
  @dataclass(frozen=True)
@@ -47,4 +49,4 @@ class ExperimentConfig:
47
49
  params: dict[str, list[object]]
48
50
  name: str | None = None
49
51
  counts: str = DEFAULT_COUNTS
50
- mode: str = "adhoc"
52
+ mode: str = DEFAULT_MODE
@@ -0,0 +1,2 @@
1
+ MODES = ("fill", "adhoc")
2
+ DEFAULT_MODE = "adhoc"
@@ -2,6 +2,7 @@ import tomllib
2
2
  from pathlib import Path
3
3
 
4
4
  from labnotes.models import LabnotesConfig
5
+ from labnotes.modes import DEFAULT_MODE, MODES
5
6
  from labnotes.read_path import DEFAULT_PATHS, read_path
6
7
 
7
8
  CONFIG_FILE = ".labnotes.toml"
@@ -16,12 +17,11 @@ def read_config(path: Path) -> LabnotesConfig:
16
17
  unknown = sorted(values.keys() - DEFAULT_PATHS.keys())
17
18
  if unknown:
18
19
  raise ValueError(f"{path}: unknown tables {', '.join(unknown)}")
20
+ paths = {
21
+ table: read_path(path, table, values.get(table, {})) for table in DEFAULT_PATHS
22
+ }
23
+ mode = values.get("experiments", {}).get("mode", DEFAULT_MODE)
24
+ if mode not in MODES:
25
+ raise ValueError(f"{path}: [experiments] mode must be fill or adhoc")
19
26
  path = path.resolve()
20
- return LabnotesConfig(
21
- path,
22
- path.parent,
23
- **{
24
- table: read_path(path, table, values.get(table, {}))
25
- for table in DEFAULT_PATHS
26
- },
27
- )
27
+ return LabnotesConfig(path, path.parent, **paths, mode=mode)
@@ -11,13 +11,15 @@ PLACEHOLDERS = {
11
11
  "files": {"experiment"},
12
12
  "runs": {"experiment", "run"},
13
13
  }
14
+ # Keys other readers own, so the path check lets them through.
15
+ OTHER_KEYS = {"experiments": {"mode"}}
14
16
  PLACEHOLDER = re.compile(r"\{([^}]*)\}")
15
17
 
16
18
 
17
19
  def read_path(config: Path, table: str, section: object) -> str:
18
20
  if not isinstance(section, dict):
19
21
  raise ValueError(f"{config}: {table} must be a table, like [{table}]")
20
- unknown = sorted(section.keys() - {"path"})
22
+ unknown = sorted(section.keys() - {"path", *OTHER_KEYS.get(table, ())})
21
23
  if unknown:
22
24
  raise ValueError(f"{config}: unknown keys in [{table}]: {', '.join(unknown)}")
23
25
  template = section.get("path", DEFAULT_PATHS[table])
@@ -4,12 +4,12 @@ from pathlib import Path
4
4
 
5
5
  from labnotes.default_counts import DEFAULT_COUNTS
6
6
  from labnotes.models import ExperimentConfig
7
+ from labnotes.modes import MODES
7
8
 
8
9
  SETTINGS = {"name", "command", "repeats", "params", "counts", "mode"}
9
- MODES = ("fill", "adhoc")
10
10
 
11
11
 
12
- def read_experiment_config(path: Path) -> ExperimentConfig:
12
+ def read_experiment_config(path: Path, mode: str) -> ExperimentConfig:
13
13
  try:
14
14
  with path.open("rb") as file:
15
15
  values = tomllib.load(file)
@@ -40,7 +40,7 @@ def read_experiment_config(path: Path) -> ExperimentConfig:
40
40
  counts = values.get("counts", DEFAULT_COUNTS)
41
41
  if not isinstance(counts, str) or not counts.strip():
42
42
  raise ValueError(f"{path}: counts must be a SQL condition")
43
- mode = values.get("mode", "adhoc")
43
+ mode = values.get("mode", mode)
44
44
  if mode not in MODES:
45
45
  raise ValueError(f"{path}: mode must be fill or adhoc")
46
46
  return ExperimentConfig(command, repeats, params, name, counts, mode)
@@ -21,7 +21,7 @@ def start_experiment(
21
21
  experiment_config = config.root / folder / "experiment.toml"
22
22
  if not experiment_config.is_file():
23
23
  raise ValueError(f"No experiment.toml for {name} at {experiment_config}.")
24
- experiment = read_experiment_config(experiment_config)
24
+ experiment = read_experiment_config(experiment_config, config.mode)
25
25
  runs = runs_parent(config.root, config.runs, name)
26
26
  runs.mkdir(parents=True, exist_ok=True)
27
27
  try:
@@ -3,7 +3,7 @@ from math import prod
3
3
  from labnotes.default_counts import DEFAULT_COUNTS
4
4
 
5
5
 
6
- def summarize_config(config: dict[str, object] | None) -> dict[str, object]:
6
+ def summarize_config(config: dict[str, object] | None, mode: str) -> dict[str, object]:
7
7
  config = config or {}
8
8
  params = config.get("params")
9
9
  varies = {
@@ -15,7 +15,7 @@ def summarize_config(config: dict[str, object] | None) -> dict[str, object]:
15
15
  cells = prod(len(values) for values in varies.values())
16
16
  command = config.get("command")
17
17
  counts = config.get("counts")
18
- fill = config.get("mode") == "fill"
18
+ fill = config.get("mode", mode) == "fill"
19
19
  return {
20
20
  "fixed": {},
21
21
  "varies": varies,
@@ -18,7 +18,9 @@ async def experiment_rows(
18
18
  case str(), "runs":
19
19
  [row] = rows
20
20
  stored = row["config"]
21
- summary = summarize_config(json.loads(str(stored)) if stored else None)
21
+ summary = summarize_config(
22
+ json.loads(str(stored)) if stored else None, config.mode
23
+ )
22
24
  return await list_experiment_runs(
23
25
  tables, name, str(summary["completed_run"])
24
26
  )
@@ -14,10 +14,12 @@ from labnotes.runner.stop_then_kill import stop_then_kill
14
14
  from labnotes.runner.param_args import param_args
15
15
  from labnotes.tui.dim import dim
16
16
  from labnotes.tui.format_elapsed import format_elapsed
17
+ from labnotes.tui.runs_table import RunsTable
17
18
 
18
19
  COLUMNS = ("run", "params", "status", "time")
19
20
  OTHER_STREAM = {"stdout": "stderr", "stderr": "stdout"}
20
- PENDING, RUNNING, PASSED, FAILED = "○", "◐", "✓", "✗"
21
+ PENDING, PASSED, FAILED = "○", "✓", "✗"
22
+ SPINNER = "|/-\\"
21
23
  SKIPPED, DESELECTED = "–", "⊘"
22
24
 
23
25
 
@@ -25,10 +27,10 @@ class RunApp(App[None]):
25
27
  BINDINGS = [
26
28
  Binding("ctrl+c", "interrupt", "Stop runs"),
27
29
  Binding("n", "run_next", "Run next"),
28
- Binding("d", "toggle_run", "Deselect"),
30
+ Binding("space", "toggle_run", "Deselect"),
29
31
  Binding("D", "deselect_shown", "Deselect shown"),
30
- Binding("k", "kill_run", "Stop run"),
31
- Binding("h", "hide_failed", "Hide failed"),
32
+ Binding("x", "kill_run", "Stop run"),
33
+ Binding("f", "hide_failed", "Hide failed"),
32
34
  Binding("r", "retry", "Retry"),
33
35
  Binding("slash", "filter", "Filter", key_display="/"),
34
36
  Binding("e", "switch_output", "stdout/stderr"),
@@ -56,6 +58,7 @@ class RunApp(App[None]):
56
58
  self.idle = False
57
59
  self.next_run: str | None = None
58
60
  self.ended: set[str] = set()
61
+ self.frame = 0
59
62
  self.started: dict[str, float] = {}
60
63
  self.pending: set[str] = set()
61
64
  self.cells: dict[str, list[object]] = {}
@@ -70,7 +73,7 @@ class RunApp(App[None]):
70
73
 
71
74
  def compose(self) -> ComposeResult:
72
75
  yield Header()
73
- yield DataTable(id="runs", cursor_type="row")
76
+ yield RunsTable(id="runs", cursor_type="row")
74
77
  yield Log(id="output")
75
78
  yield Input(id="filter", placeholder="Filter params")
76
79
  yield Static(id="status")
@@ -82,6 +85,7 @@ class RunApp(App[None]):
82
85
  table.add_column(column, key=column)
83
86
  self.query_one(Input).display = False
84
87
  self.query_one(Log).display = False
88
+ table.set_interval(0.1, self.spin)
85
89
 
86
90
  def on_ready(self) -> None:
87
91
  self.set_interval(1, self.tick)
@@ -120,7 +124,7 @@ class RunApp(App[None]):
120
124
  if run == self.next_run:
121
125
  self.show("")
122
126
  args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
123
- cells = (run, args, RUNNING, format_elapsed(0))
127
+ cells = (run, args, SPINNER[self.frame], format_elapsed(0))
124
128
  for column, cell in zip(COLUMNS, cells):
125
129
  self.set_cell(run, column, cell)
126
130
  case "ended":
@@ -205,6 +209,11 @@ class RunApp(App[None]):
205
209
  for run, began in self.started.items():
206
210
  self.set_cell(run, "time", format_elapsed(time.monotonic() - began))
207
211
 
212
+ def spin(self) -> None:
213
+ self.frame = (self.frame + 1) % len(SPINNER)
214
+ for run in self.started:
215
+ self.set_cell(run, "status", SPINNER[self.frame])
216
+
208
217
  def show(self, text: str) -> None:
209
218
  self.next_run = None
210
219
  self.query_one("#status", Static).update(text)
@@ -0,0 +1,11 @@
1
+ from textual.binding import Binding
2
+ from textual.widgets import DataTable
3
+
4
+
5
+ class RunsTable(DataTable[object]):
6
+ BINDINGS = [
7
+ Binding("j", "cursor_down", show=False),
8
+ Binding("k", "cursor_up", show=False),
9
+ Binding("g", "scroll_top", show=False),
10
+ Binding("G", "scroll_bottom", show=False),
11
+ ]
@@ -223,6 +223,19 @@ wheels = [
223
223
  { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
224
224
  ]
225
225
 
226
+ [[package]]
227
+ name = "curtaincall"
228
+ version = "0.3.0"
229
+ source = { registry = "https://pypi.org/simple" }
230
+ dependencies = [
231
+ { name = "pexpect" },
232
+ { name = "pyte" },
233
+ ]
234
+ sdist = { url = "https://files.pythonhosted.org/packages/ca/12/056a3c89fcbc135b15a7cef872472a73bdad3594ddfccbe11b9a028b2f5f/curtaincall-0.3.0.tar.gz", hash = "sha256:afc9e9ce00a256982d69a737976527d38217082ca79fcafcf381fa2cd2bba041", size = 68784, upload-time = "2026-02-16T01:44:39.874Z" }
235
+ wheels = [
236
+ { url = "https://files.pythonhosted.org/packages/f8/52/1092ca83246b88a880f2fe35e38919b9e069c0ce97708880c4234ab72c32/curtaincall-0.3.0-py3-none-any.whl", hash = "sha256:4a754fa4a8ad726a00ec24ebb545f8f5a34a0f8d159936845d125bfeb2dfb652", size = 21504, upload-time = "2026-02-16T01:44:38.567Z" },
237
+ ]
238
+
226
239
  [[package]]
227
240
  name = "debugpy"
228
241
  version = "1.8.22"
@@ -477,6 +490,7 @@ dependencies = [
477
490
 
478
491
  [package.dev-dependencies]
479
492
  dev = [
493
+ { name = "curtaincall" },
480
494
  { name = "httpx2" },
481
495
  { name = "mypy" },
482
496
  { name = "pytest" },
@@ -500,6 +514,7 @@ requires-dist = [
500
514
 
501
515
  [package.metadata.requires-dev]
502
516
  dev = [
517
+ { name = "curtaincall", specifier = ">=0.3.0" },
503
518
  { name = "httpx2", specifier = ">=2.13.1" },
504
519
  { name = "mypy", specifier = ">=2.4.0" },
505
520
  { name = "pytest", specifier = ">=8.0" },
@@ -984,6 +999,18 @@ wheels = [
984
999
  { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
985
1000
  ]
986
1001
 
1002
+ [[package]]
1003
+ name = "pyte"
1004
+ version = "0.8.2"
1005
+ source = { registry = "https://pypi.org/simple" }
1006
+ dependencies = [
1007
+ { name = "wcwidth" },
1008
+ ]
1009
+ sdist = { url = "https://files.pythonhosted.org/packages/ab/ab/b599762933eba04de7dc5b31ae083112a6c9a9db15b01d3109ad797559d9/pyte-0.8.2.tar.gz", hash = "sha256:5af970e843fa96a97149d64e170c984721f20e52227a2f57f0a54207f08f083f", size = 92301, upload-time = "2023-11-12T09:33:43.217Z" }
1010
+ wheels = [
1011
+ { url = "https://files.pythonhosted.org/packages/59/d0/bb522283b90853afbf506cd5b71c650cf708829914efd0003d615cf426cd/pyte-0.8.2-py3-none-any.whl", hash = "sha256:85db42a35798a5aafa96ac4d8da78b090b2c933248819157fc0e6f78876a0135", size = 31627, upload-time = "2023-11-12T09:33:41.096Z" },
1012
+ ]
1013
+
987
1014
  [[package]]
988
1015
  name = "pytest"
989
1016
  version = "9.1.1"
File without changes
File without changes
File without changes
File without changes
File without changes