bathos 0.1.0__py3-none-any.whl
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.
- bathos/__init__.py +1 -0
- bathos/archive.py +161 -0
- bathos/catalog.py +39 -0
- bathos/checker.py +107 -0
- bathos/cli.py +337 -0
- bathos/compact.py +268 -0
- bathos/config.py +37 -0
- bathos/git.py +39 -0
- bathos/init.py +70 -0
- bathos/mcp.py +500 -0
- bathos/query.py +364 -0
- bathos/runner.py +53 -0
- bathos/schema.py +122 -0
- bathos/sync.py +94 -0
- bathos/templates/_bth_env.sh +6 -0
- bathos-0.1.0.dist-info/METADATA +115 -0
- bathos-0.1.0.dist-info/RECORD +20 -0
- bathos-0.1.0.dist-info/WHEEL +4 -0
- bathos-0.1.0.dist-info/entry_points.txt +3 -0
- bathos-0.1.0.dist-info/licenses/LICENSE +190 -0
bathos/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
bathos/archive.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import duckdb
|
|
10
|
+
import pyarrow as pa
|
|
11
|
+
import pyarrow.parquet as pq
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ArchiveResult:
|
|
16
|
+
"""Result of an archive operation."""
|
|
17
|
+
|
|
18
|
+
runs_archived: int
|
|
19
|
+
partitions_created: int
|
|
20
|
+
archive_size_bytes: int
|
|
21
|
+
manifest_path: Path
|
|
22
|
+
duration_s: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def archive(
|
|
26
|
+
catalog_dir: Path,
|
|
27
|
+
archive_root: Path | None = None,
|
|
28
|
+
project_slug: str | None = None,
|
|
29
|
+
dry_run: bool = False,
|
|
30
|
+
) -> ArchiveResult:
|
|
31
|
+
"""Export warm-tier runs to cold-tier partitioned Parquet.
|
|
32
|
+
|
|
33
|
+
Strategy: Partition by project_slug, year, month for efficient bulk queries.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
catalog_dir: Path to catalog root
|
|
37
|
+
archive_root: Root for archive (default ~/.bth/archive/)
|
|
38
|
+
project_slug: Filter to specific project (default: all)
|
|
39
|
+
dry_run: If True, show what would be archived without writing
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
ArchiveResult with counts, size, manifest path, duration
|
|
43
|
+
"""
|
|
44
|
+
start_time = time.time()
|
|
45
|
+
|
|
46
|
+
if archive_root is None:
|
|
47
|
+
archive_root = Path.home() / ".bth" / "archive"
|
|
48
|
+
|
|
49
|
+
if not dry_run:
|
|
50
|
+
archive_root.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# Check for warm DB
|
|
53
|
+
db_path = catalog_dir / "bathos.db"
|
|
54
|
+
if not db_path.exists():
|
|
55
|
+
raise RuntimeError("No warm catalog. Run `bth compact` first.")
|
|
56
|
+
|
|
57
|
+
# Read runs from warm DB
|
|
58
|
+
con = duckdb.connect(str(db_path), read_only=True)
|
|
59
|
+
|
|
60
|
+
query = "SELECT * FROM runs ORDER BY id"
|
|
61
|
+
params = []
|
|
62
|
+
if project_slug:
|
|
63
|
+
query = "SELECT * FROM runs WHERE project_slug = ? ORDER BY id"
|
|
64
|
+
params = [project_slug]
|
|
65
|
+
|
|
66
|
+
results = con.execute(query, params).to_arrow_table()
|
|
67
|
+
con.close()
|
|
68
|
+
|
|
69
|
+
if len(results) == 0:
|
|
70
|
+
duration_s = time.time() - start_time
|
|
71
|
+
return ArchiveResult(
|
|
72
|
+
runs_archived=0,
|
|
73
|
+
partitions_created=0,
|
|
74
|
+
archive_size_bytes=0,
|
|
75
|
+
manifest_path=archive_root / "manifest.json",
|
|
76
|
+
duration_s=duration_s,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Partition by project, year, month
|
|
80
|
+
partitions: dict[tuple[str, int, str], list[int]] = {}
|
|
81
|
+
|
|
82
|
+
timestamp_col = results.column("timestamp")
|
|
83
|
+
project_col = results.column("project_slug")
|
|
84
|
+
|
|
85
|
+
for i in range(len(results)):
|
|
86
|
+
# Extract project and timestamp
|
|
87
|
+
project = project_col[i].as_py()
|
|
88
|
+
ts = timestamp_col[i].as_py()
|
|
89
|
+
|
|
90
|
+
# Handle both int (microseconds) and datetime
|
|
91
|
+
dt = datetime.fromtimestamp(ts / 1000000.0) if isinstance(ts, int) else ts
|
|
92
|
+
|
|
93
|
+
year = dt.year
|
|
94
|
+
month = f"{dt.month:02d}"
|
|
95
|
+
|
|
96
|
+
key = (project, year, month)
|
|
97
|
+
if key not in partitions:
|
|
98
|
+
partitions[key] = []
|
|
99
|
+
partitions[key].append(i)
|
|
100
|
+
|
|
101
|
+
# Write partitions
|
|
102
|
+
total_archived = 0
|
|
103
|
+
partitions_created = 0
|
|
104
|
+
total_size = 0
|
|
105
|
+
manifest_entries = []
|
|
106
|
+
|
|
107
|
+
for (project, year, month), indices in sorted(partitions.items()):
|
|
108
|
+
partition_path = archive_root / f"project={project}" / f"year={year}" / f"month={month}"
|
|
109
|
+
output_file = partition_path / "runs.parquet"
|
|
110
|
+
|
|
111
|
+
if not dry_run:
|
|
112
|
+
partition_path.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
|
|
114
|
+
# Filter results to just these rows
|
|
115
|
+
indices_array = pa.array(indices)
|
|
116
|
+
mask = pa.compute.is_in(pa.array(list(range(len(results)))), indices_array)
|
|
117
|
+
filtered = results.filter(mask)
|
|
118
|
+
|
|
119
|
+
# Write atomically: write to temp, then rename
|
|
120
|
+
temp_file = output_file.parent / f".{output_file.name}.tmp"
|
|
121
|
+
pq.write_table(filtered, str(temp_file))
|
|
122
|
+
temp_file.rename(output_file)
|
|
123
|
+
|
|
124
|
+
file_size = output_file.stat().st_size
|
|
125
|
+
total_size += file_size
|
|
126
|
+
else:
|
|
127
|
+
file_size = 0
|
|
128
|
+
|
|
129
|
+
manifest_entries.append(
|
|
130
|
+
{
|
|
131
|
+
"partition": f"project={project}/year={year}/month={month}",
|
|
132
|
+
"rows": len(indices),
|
|
133
|
+
"size_bytes": file_size if not dry_run else 0,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
total_archived += len(indices)
|
|
138
|
+
partitions_created += 1
|
|
139
|
+
|
|
140
|
+
# Write manifest
|
|
141
|
+
manifest_path = archive_root / "manifest.json"
|
|
142
|
+
if not dry_run:
|
|
143
|
+
manifest = {
|
|
144
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
145
|
+
"runs_archived": total_archived,
|
|
146
|
+
"partitions": partitions_created,
|
|
147
|
+
"total_size_bytes": total_size,
|
|
148
|
+
"entries": manifest_entries,
|
|
149
|
+
}
|
|
150
|
+
with open(manifest_path, "w") as f:
|
|
151
|
+
json.dump(manifest, f, indent=2)
|
|
152
|
+
|
|
153
|
+
duration_s = time.time() - start_time
|
|
154
|
+
|
|
155
|
+
return ArchiveResult(
|
|
156
|
+
runs_archived=total_archived,
|
|
157
|
+
partitions_created=partitions_created,
|
|
158
|
+
archive_size_bytes=total_size,
|
|
159
|
+
manifest_path=manifest_path,
|
|
160
|
+
duration_s=duration_s,
|
|
161
|
+
)
|
bathos/catalog.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pyarrow as pa
|
|
6
|
+
import pyarrow.compute as pc
|
|
7
|
+
import pyarrow.parquet as pq
|
|
8
|
+
|
|
9
|
+
from bathos.schema import Run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def init_catalog(catalog_dir: Path) -> None:
|
|
13
|
+
(catalog_dir / "runs").mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_run(run: Run, catalog_dir: Path) -> None:
|
|
17
|
+
"""Write (or overwrite) a run record atomically."""
|
|
18
|
+
runs_dir = catalog_dir / "runs"
|
|
19
|
+
runs_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
target = runs_dir / f"run_{run.id}.parquet"
|
|
21
|
+
tmp = runs_dir / f"run_{run.id}.tmp.parquet"
|
|
22
|
+
pq.write_table(run.to_arrow(), tmp)
|
|
23
|
+
tmp.rename(target) # atomic on POSIX
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_runs(catalog_dir: Path) -> list[Run]:
|
|
27
|
+
"""Read all runs from Parquet fragments, sorted by timestamp DESC."""
|
|
28
|
+
runs_dir = catalog_dir / "runs"
|
|
29
|
+
if not runs_dir.exists():
|
|
30
|
+
return []
|
|
31
|
+
parquet_files = list(runs_dir.glob("run_*.parquet"))
|
|
32
|
+
if not parquet_files:
|
|
33
|
+
return []
|
|
34
|
+
tables = [pq.read_table(f) for f in parquet_files]
|
|
35
|
+
combined = pa.concat_tables(tables)
|
|
36
|
+
order = pc.sort_indices(combined, sort_keys=[("timestamp", "descending")])
|
|
37
|
+
combined = combined.take(order)
|
|
38
|
+
pydict = combined.to_pydict()
|
|
39
|
+
return [Run.from_arrow_row(pydict, i) for i in range(combined.num_rows)]
|
bathos/checker.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from bathos.git import capture_git_state
|
|
8
|
+
from bathos.query import list_runs
|
|
9
|
+
from bathos.schema import Run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CheckResult:
|
|
14
|
+
"""Result of checking a single run's git-drift validity."""
|
|
15
|
+
|
|
16
|
+
run_id: str
|
|
17
|
+
status: Literal["OK", "STALE", "DIRTY_RUN", "UNKNOWN_CODE"]
|
|
18
|
+
run_git_hash: str
|
|
19
|
+
current_hash: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class OutputCheckResult:
|
|
24
|
+
"""Result of checking a single output file."""
|
|
25
|
+
|
|
26
|
+
path: str
|
|
27
|
+
status: str # "present", "missing", "unreadable"
|
|
28
|
+
size_bytes: int = 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_runs(
|
|
32
|
+
catalog_dir: Path,
|
|
33
|
+
project_root: Path,
|
|
34
|
+
status_filter: str | None = None,
|
|
35
|
+
) -> list[CheckResult]:
|
|
36
|
+
"""Check all runs in catalog for git-drift validity.
|
|
37
|
+
|
|
38
|
+
For each run:
|
|
39
|
+
- STALE: run's git_hash != current HEAD and run's git_dirty was False
|
|
40
|
+
- DIRTY_RUN: run's git_dirty was True
|
|
41
|
+
- UNKNOWN_CODE: run's git_hash == "unknown"
|
|
42
|
+
- OK: otherwise (hash matches current or dirty was True)
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
catalog_dir: Path to catalog directory
|
|
46
|
+
project_root: Path to project root (used to get current git state)
|
|
47
|
+
status_filter: Optional filter; return only results with this status
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of CheckResult objects
|
|
51
|
+
"""
|
|
52
|
+
# Get current git state
|
|
53
|
+
current_state = capture_git_state(project_root)
|
|
54
|
+
current_hash = current_state.hash
|
|
55
|
+
|
|
56
|
+
# Get all runs from catalog
|
|
57
|
+
all_runs = list_runs(catalog_dir)
|
|
58
|
+
|
|
59
|
+
results = []
|
|
60
|
+
for run in all_runs:
|
|
61
|
+
if run.git_hash == "unknown":
|
|
62
|
+
status = "UNKNOWN_CODE"
|
|
63
|
+
elif run.git_dirty:
|
|
64
|
+
status = "DIRTY_RUN"
|
|
65
|
+
elif run.git_hash != current_hash:
|
|
66
|
+
status = "STALE"
|
|
67
|
+
else:
|
|
68
|
+
status = "OK"
|
|
69
|
+
|
|
70
|
+
result = CheckResult(
|
|
71
|
+
run_id=run.id,
|
|
72
|
+
status=status,
|
|
73
|
+
run_git_hash=run.git_hash,
|
|
74
|
+
current_hash=current_hash,
|
|
75
|
+
)
|
|
76
|
+
results.append(result)
|
|
77
|
+
|
|
78
|
+
# Apply filter if provided
|
|
79
|
+
if status_filter:
|
|
80
|
+
results = [r for r in results if r.status == status_filter]
|
|
81
|
+
|
|
82
|
+
return results
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_output_files(run: Run) -> list[OutputCheckResult]:
|
|
86
|
+
"""Verify output files exist and are readable.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
run: Run object with output_paths
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of OutputCheckResult for each file
|
|
93
|
+
"""
|
|
94
|
+
from bathos.compact import _collect_output_metadata
|
|
95
|
+
|
|
96
|
+
results = []
|
|
97
|
+
for path in run.output_paths:
|
|
98
|
+
meta = _collect_output_metadata(path)
|
|
99
|
+
results.append(
|
|
100
|
+
OutputCheckResult(
|
|
101
|
+
path=path,
|
|
102
|
+
status=meta["status"],
|
|
103
|
+
size_bytes=meta.get("size_bytes", 0),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return results
|
bathos/cli.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from bathos.archive import archive
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="bathos — local-first experiment tracking")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _catalog_dir() -> Path:
|
|
15
|
+
override = os.environ.get("BTH_CATALOG_DIR")
|
|
16
|
+
if override:
|
|
17
|
+
return Path(override)
|
|
18
|
+
from bathos.config import default_catalog_dir
|
|
19
|
+
|
|
20
|
+
return default_catalog_dir()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _require_project_slug() -> str:
|
|
24
|
+
slug_env = os.environ.get("BTH_PROJECT_SLUG")
|
|
25
|
+
if slug_env:
|
|
26
|
+
return slug_env
|
|
27
|
+
from bathos.config import find_project_config, load_project_config
|
|
28
|
+
|
|
29
|
+
cfg_path = find_project_config()
|
|
30
|
+
if cfg_path is None:
|
|
31
|
+
typer.echo("No .bth.toml found. Run `bth init` first.", err=True)
|
|
32
|
+
raise typer.Exit(1)
|
|
33
|
+
return load_project_config(cfg_path).slug
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback(invoke_without_command=True)
|
|
37
|
+
def main(
|
|
38
|
+
version: bool = typer.Option(False, "--version", "-V", is_eager=True),
|
|
39
|
+
):
|
|
40
|
+
if version:
|
|
41
|
+
from bathos import __version__
|
|
42
|
+
|
|
43
|
+
typer.echo(f"bathos {__version__}")
|
|
44
|
+
raise typer.Exit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def init(
|
|
49
|
+
slug: str = typer.Option(..., "--slug", "-s", help="Project slug"),
|
|
50
|
+
remote: str | None = typer.Option(None, "--remote", help="host:remote_path"),
|
|
51
|
+
slurm_partition: str | None = typer.Option(None, "--slurm-partition"),
|
|
52
|
+
):
|
|
53
|
+
"""Register project, scaffold scripts/ dirs, write .bth.toml."""
|
|
54
|
+
from bathos.init import init_project
|
|
55
|
+
|
|
56
|
+
init_project(
|
|
57
|
+
Path.cwd(),
|
|
58
|
+
slug=slug,
|
|
59
|
+
catalog_dir=_catalog_dir(),
|
|
60
|
+
remote=remote,
|
|
61
|
+
slurm_partition=slurm_partition,
|
|
62
|
+
)
|
|
63
|
+
typer.echo(f"Initialized bathos project '{slug}'")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
|
67
|
+
def run(
|
|
68
|
+
argv: list[str] = typer.Argument(...),
|
|
69
|
+
out: list[str] = typer.Option([], "--out", help="Output path to register"),
|
|
70
|
+
tag: list[str] = typer.Option([], "--tag", "-t"),
|
|
71
|
+
):
|
|
72
|
+
"""Run a script and record provenance."""
|
|
73
|
+
from bathos.runner import run_script
|
|
74
|
+
|
|
75
|
+
slug = _require_project_slug()
|
|
76
|
+
exit_code = run_script(
|
|
77
|
+
argv=argv,
|
|
78
|
+
project_slug=slug,
|
|
79
|
+
catalog_dir=_catalog_dir(),
|
|
80
|
+
output_paths=out,
|
|
81
|
+
tags=tag,
|
|
82
|
+
)
|
|
83
|
+
raise typer.Exit(exit_code)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("ls")
|
|
87
|
+
def ls_cmd(
|
|
88
|
+
project: str | None = typer.Option(None, "--project", "-p"),
|
|
89
|
+
since: str | None = typer.Option(None, "--since", help="e.g. 7d, 24h"),
|
|
90
|
+
status: str | None = typer.Option(None, "--status"),
|
|
91
|
+
limit: int = typer.Option(20, "--limit", "-n"),
|
|
92
|
+
):
|
|
93
|
+
"""List recent runs."""
|
|
94
|
+
from bathos.compact import _fragment_count, should_compact
|
|
95
|
+
from bathos.query import find_runs
|
|
96
|
+
|
|
97
|
+
since_dt = _parse_since(since)
|
|
98
|
+
catalog_dir = _catalog_dir()
|
|
99
|
+
runs = find_runs(catalog_dir, since=since_dt, project=project, status=status)
|
|
100
|
+
runs = runs[:limit]
|
|
101
|
+
if not runs:
|
|
102
|
+
typer.echo("No runs found.")
|
|
103
|
+
return
|
|
104
|
+
header = f"{'ID':38} {'PROJECT':12} {'STATUS':10} {'EXIT':5} {'DURATION':8} COMMAND"
|
|
105
|
+
typer.echo(header)
|
|
106
|
+
typer.echo("-" * len(header))
|
|
107
|
+
for r in runs:
|
|
108
|
+
typer.echo(
|
|
109
|
+
f"{r.id:38} {r.project_slug:12} {r.status:10} {r.exit_code:5} "
|
|
110
|
+
f"{r.duration_s:7.1f}s {r.command[:40]}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Check if compaction is recommended and show banner if needed
|
|
114
|
+
if should_compact(catalog_dir):
|
|
115
|
+
frag_count = _fragment_count(catalog_dir)
|
|
116
|
+
typer.echo()
|
|
117
|
+
typer.echo(f"⚠ {frag_count} uncompacted runs — run 'bth compact' to speed up queries")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def show(run_id: str = typer.Argument(...)):
|
|
122
|
+
"""Show full details of a run."""
|
|
123
|
+
from bathos.query import get_run
|
|
124
|
+
|
|
125
|
+
r = get_run(run_id, _catalog_dir())
|
|
126
|
+
if r is None:
|
|
127
|
+
typer.echo(f"Run not found: {run_id}", err=True)
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
typer.echo(f"id: {r.id}")
|
|
130
|
+
typer.echo(f"project: {r.project_slug}")
|
|
131
|
+
typer.echo(f"status: {r.status}")
|
|
132
|
+
typer.echo(f"exit_code: {r.exit_code}")
|
|
133
|
+
typer.echo(f"duration: {r.duration_s:.2f}s")
|
|
134
|
+
typer.echo(f"git_hash: {r.git_hash}")
|
|
135
|
+
typer.echo(f"git_branch: {r.git_branch}")
|
|
136
|
+
typer.echo(f"git_dirty: {r.git_dirty}")
|
|
137
|
+
typer.echo(f"timestamp: {r.timestamp.isoformat()}")
|
|
138
|
+
typer.echo(f"command: {r.command}")
|
|
139
|
+
typer.echo(f"output_paths: {r.output_paths}")
|
|
140
|
+
typer.echo(f"tags: {r.tags}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def find(
|
|
145
|
+
project: str | None = typer.Option(None, "--project", "-p"),
|
|
146
|
+
since: str | None = typer.Option(None, "--since"),
|
|
147
|
+
status: str | None = typer.Option(None, "--status"),
|
|
148
|
+
tag: list[str] = typer.Option([], "--tag"),
|
|
149
|
+
slurm_job: str | None = typer.Option(None, "--slurm-job", help="SLURM job ID"),
|
|
150
|
+
output_file: str | None = typer.Option(
|
|
151
|
+
None, "--output-file", help="Filter to runs with matching output file path (glob pattern)"
|
|
152
|
+
),
|
|
153
|
+
):
|
|
154
|
+
"""Find runs matching filters."""
|
|
155
|
+
from bathos.query import _filter_runs_by_output_file, find_runs
|
|
156
|
+
|
|
157
|
+
runs = find_runs(
|
|
158
|
+
_catalog_dir(),
|
|
159
|
+
since=_parse_since(since),
|
|
160
|
+
project=project,
|
|
161
|
+
status=status,
|
|
162
|
+
tags=tag or None,
|
|
163
|
+
slurm_job_id=slurm_job,
|
|
164
|
+
)
|
|
165
|
+
runs = _filter_runs_by_output_file(runs, pattern=output_file)
|
|
166
|
+
for r in runs:
|
|
167
|
+
typer.echo(f"{r.id} {r.project_slug} {r.status} {r.command[:60]}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def sql(query: str = typer.Argument(...)):
|
|
172
|
+
"""Run raw DuckDB SQL against the catalog."""
|
|
173
|
+
from bathos.query import run_sql
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
rows = run_sql(query, catalog_dir=_catalog_dir())
|
|
177
|
+
for row in rows:
|
|
178
|
+
typer.echo("\t".join(str(v) for v in row))
|
|
179
|
+
except RuntimeError as e:
|
|
180
|
+
typer.echo(str(e), err=True)
|
|
181
|
+
raise typer.Exit(1)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command()
|
|
185
|
+
def compact():
|
|
186
|
+
"""Compact cool fragments into warm DuckDB catalog."""
|
|
187
|
+
from bathos.compact import compact as compact_catalog
|
|
188
|
+
|
|
189
|
+
catalog_dir = _catalog_dir()
|
|
190
|
+
result = compact_catalog(catalog_dir)
|
|
191
|
+
typer.echo(f"Compacted {result.ingested} runs into bathos.db in {result.duration_s:.1f}s")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command("archive")
|
|
195
|
+
def archive_cmd(
|
|
196
|
+
project: str | None = typer.Option(
|
|
197
|
+
None, "--project", "-p", help="Filter to specific project (default: all)"
|
|
198
|
+
),
|
|
199
|
+
archive_dir: Path | None = typer.Option(
|
|
200
|
+
None, "--archive-dir", "-d", help="Archive root directory (default: ~/.bth/archive)"
|
|
201
|
+
),
|
|
202
|
+
dry_run: bool = typer.Option(
|
|
203
|
+
False, "--dry-run", help="Show what would be archived without writing"
|
|
204
|
+
),
|
|
205
|
+
):
|
|
206
|
+
"""Archive old runs to partitioned cold storage."""
|
|
207
|
+
catalog_dir = _catalog_dir()
|
|
208
|
+
|
|
209
|
+
if not catalog_dir.exists():
|
|
210
|
+
typer.secho("✗ Catalog not found", fg="red")
|
|
211
|
+
raise typer.Exit(1)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
result = archive(
|
|
215
|
+
catalog_dir,
|
|
216
|
+
archive_root=archive_dir,
|
|
217
|
+
project_slug=project,
|
|
218
|
+
dry_run=dry_run,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
status = "[dry-run] " if dry_run else ""
|
|
222
|
+
typer.secho(
|
|
223
|
+
f"✓ {status}Archived {result.runs_archived} runs into "
|
|
224
|
+
f"{result.partitions_created} partitions in {result.duration_s:.1f}s",
|
|
225
|
+
fg="green",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if not dry_run:
|
|
229
|
+
typer.secho(f" Manifest: {result.manifest_path}", fg="cyan")
|
|
230
|
+
except RuntimeError as e:
|
|
231
|
+
typer.secho(f"✗ {str(e)}", fg="red")
|
|
232
|
+
raise typer.Exit(1)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def check(
|
|
237
|
+
status: str | None = typer.Option(
|
|
238
|
+
None, "--status", help="Filter by status (OK, STALE, DIRTY_RUN, UNKNOWN_CODE)"
|
|
239
|
+
),
|
|
240
|
+
check_outputs: bool = typer.Option(
|
|
241
|
+
False, "--check-outputs", help="Also verify output files exist and are readable"
|
|
242
|
+
),
|
|
243
|
+
):
|
|
244
|
+
"""Check runs for git-drift validity against current HEAD."""
|
|
245
|
+
from bathos.checker import check_output_files, check_runs
|
|
246
|
+
from bathos.query import get_run
|
|
247
|
+
|
|
248
|
+
catalog_dir = _catalog_dir()
|
|
249
|
+
project_root = Path.cwd()
|
|
250
|
+
|
|
251
|
+
results = check_runs(catalog_dir, project_root, status_filter=status)
|
|
252
|
+
|
|
253
|
+
if not results:
|
|
254
|
+
if status:
|
|
255
|
+
typer.echo(f"No runs found with status={status}")
|
|
256
|
+
else:
|
|
257
|
+
typer.echo("No runs found in catalog")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
# Print header
|
|
261
|
+
header = f"{'RUN_ID':38} {'STATUS':12} {'RUN_HASH':40} {'CURRENT_HASH':40}"
|
|
262
|
+
typer.echo(header)
|
|
263
|
+
typer.echo("-" * len(header))
|
|
264
|
+
|
|
265
|
+
# Print results
|
|
266
|
+
stale_count = 0
|
|
267
|
+
for result in results:
|
|
268
|
+
typer.echo(
|
|
269
|
+
f"{result.run_id:38} {result.status:12} {result.run_git_hash:40} {result.current_hash:40}"
|
|
270
|
+
)
|
|
271
|
+
if result.status == "STALE":
|
|
272
|
+
stale_count += 1
|
|
273
|
+
|
|
274
|
+
# Check output files if requested
|
|
275
|
+
if check_outputs:
|
|
276
|
+
typer.secho("\n[Output File Status]", fg="cyan")
|
|
277
|
+
for result in results:
|
|
278
|
+
run = get_run(result.run_id, catalog_dir)
|
|
279
|
+
if run is None:
|
|
280
|
+
continue
|
|
281
|
+
output_results = check_output_files(run)
|
|
282
|
+
if output_results:
|
|
283
|
+
for out_result in output_results:
|
|
284
|
+
status_color = "green" if out_result.status == "present" else "red"
|
|
285
|
+
typer.secho(
|
|
286
|
+
f" {result.run_id}: {out_result.path} ({out_result.status})",
|
|
287
|
+
fg=status_color,
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
typer.secho(f" {result.run_id}: no output files", fg="dim")
|
|
291
|
+
|
|
292
|
+
# Exit with error if any STALE runs found
|
|
293
|
+
if stale_count > 0:
|
|
294
|
+
typer.echo()
|
|
295
|
+
typer.echo(f"Warning: {stale_count} stale run(s) detected", err=True)
|
|
296
|
+
raise typer.Exit(1)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.command()
|
|
300
|
+
def sync(
|
|
301
|
+
remote: str = typer.Argument(..., help="Remote name from .bth.toml"),
|
|
302
|
+
pull: bool = typer.Option(False, "--pull", help="Pull from remote (default: push)"),
|
|
303
|
+
):
|
|
304
|
+
"""Sync cool-tier catalog to/from remote."""
|
|
305
|
+
from bathos.config import find_project_config, load_project_config
|
|
306
|
+
from bathos.sync import sync_catalog
|
|
307
|
+
|
|
308
|
+
cfg_path = find_project_config()
|
|
309
|
+
if cfg_path is None:
|
|
310
|
+
typer.echo("No .bth.toml found. Run `bth init` first.", err=True)
|
|
311
|
+
raise typer.Exit(1)
|
|
312
|
+
|
|
313
|
+
config = load_project_config(cfg_path)
|
|
314
|
+
catalog_dir = _catalog_dir()
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
result = sync_catalog(remote, config, catalog_dir, pull=pull)
|
|
318
|
+
direction = "pulled" if pull else "pushed"
|
|
319
|
+
typer.echo(
|
|
320
|
+
f"{direction.capitalize()} {result.transferred} runs to/from '{result.remote}' in {result.duration_s:.1f}s"
|
|
321
|
+
)
|
|
322
|
+
except ValueError as e:
|
|
323
|
+
typer.echo(str(e), err=True)
|
|
324
|
+
raise typer.Exit(1)
|
|
325
|
+
except RuntimeError as e:
|
|
326
|
+
typer.echo(str(e), err=True)
|
|
327
|
+
raise typer.Exit(1)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _parse_since(since: str | None) -> datetime | None:
|
|
331
|
+
if since is None:
|
|
332
|
+
return None
|
|
333
|
+
if since.endswith("d"):
|
|
334
|
+
return datetime.now(UTC) - timedelta(days=float(since[:-1]))
|
|
335
|
+
if since.endswith("h"):
|
|
336
|
+
return datetime.now(UTC) - timedelta(hours=float(since[:-1]))
|
|
337
|
+
return None
|