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 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