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/compact.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import duckdb
|
|
11
|
+
|
|
12
|
+
from bathos.catalog import read_runs
|
|
13
|
+
from bathos.schema import Run
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _collect_output_metadata(output_path: str) -> dict:
|
|
17
|
+
"""Collect metadata about an output file.
|
|
18
|
+
|
|
19
|
+
Returns dict with:
|
|
20
|
+
- status: "present", "missing", or "unreadable"
|
|
21
|
+
- size_bytes: file size (0 if missing/unreadable)
|
|
22
|
+
- mtime_unix: modification time (Unix timestamp)
|
|
23
|
+
- sha256: file hash (None if >100MB or unreadable)
|
|
24
|
+
"""
|
|
25
|
+
path = Path(output_path)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
if not path.exists():
|
|
29
|
+
return {"status": "missing", "size_bytes": 0}
|
|
30
|
+
|
|
31
|
+
stat = path.stat()
|
|
32
|
+
size_bytes = stat.st_size
|
|
33
|
+
mtime_unix = stat.st_mtime
|
|
34
|
+
|
|
35
|
+
# Skip SHA256 for large files (>100MB)
|
|
36
|
+
sha256_hash = None
|
|
37
|
+
if size_bytes < 100 * 1024 * 1024:
|
|
38
|
+
try:
|
|
39
|
+
h = hashlib.sha256()
|
|
40
|
+
with open(path, "rb") as f:
|
|
41
|
+
while chunk := f.read(8192):
|
|
42
|
+
h.update(chunk)
|
|
43
|
+
sha256_hash = h.hexdigest()
|
|
44
|
+
except Exception:
|
|
45
|
+
sha256_hash = None
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"status": "present",
|
|
49
|
+
"size_bytes": size_bytes,
|
|
50
|
+
"mtime_unix": mtime_unix,
|
|
51
|
+
"sha256": sha256_hash,
|
|
52
|
+
}
|
|
53
|
+
except (PermissionError, OSError):
|
|
54
|
+
return {"status": "unreadable", "size_bytes": 0}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class CompactResult:
|
|
59
|
+
"""Result of a compact operation."""
|
|
60
|
+
|
|
61
|
+
ingested: int
|
|
62
|
+
skipped: int
|
|
63
|
+
duration_s: float
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Migration registry: transforms Run objects from older schema versions to current
|
|
67
|
+
MIGRATIONS: dict[str, Callable[[dict], dict]] = {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _migrate_v0(run_dict: dict) -> dict:
|
|
71
|
+
"""Migrate v0 fragment (missing schema_version) to v1."""
|
|
72
|
+
# v0 runs have no schema_version; set to "1" for compatibility
|
|
73
|
+
run_dict["schema_version"] = "1"
|
|
74
|
+
return run_dict
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _migrate_v1(run_dict: dict) -> dict:
|
|
78
|
+
"""Migrate v1 fragment (no hostname) to v2.
|
|
79
|
+
|
|
80
|
+
v1 fragments have schema_version='1' but no hostname field.
|
|
81
|
+
This migration adds hostname (defaults to "") and updates version.
|
|
82
|
+
"""
|
|
83
|
+
run_dict["hostname"] = ""
|
|
84
|
+
run_dict["schema_version"] = "2"
|
|
85
|
+
return run_dict
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
MIGRATIONS["0"] = _migrate_v0
|
|
89
|
+
MIGRATIONS["1"] = _migrate_v1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_RUNS_TABLE_SCHEMA = """
|
|
93
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
94
|
+
id TEXT PRIMARY KEY,
|
|
95
|
+
project_slug TEXT,
|
|
96
|
+
command TEXT,
|
|
97
|
+
argv TEXT[],
|
|
98
|
+
git_hash TEXT,
|
|
99
|
+
git_branch TEXT,
|
|
100
|
+
git_dirty BOOLEAN,
|
|
101
|
+
timestamp TIMESTAMP WITH TIME ZONE,
|
|
102
|
+
duration_s DOUBLE,
|
|
103
|
+
exit_code INTEGER,
|
|
104
|
+
status TEXT,
|
|
105
|
+
output_paths TEXT[],
|
|
106
|
+
tags TEXT[],
|
|
107
|
+
schema_version TEXT,
|
|
108
|
+
slurm_job_id TEXT,
|
|
109
|
+
hostname TEXT,
|
|
110
|
+
metadata TEXT,
|
|
111
|
+
outcome TEXT,
|
|
112
|
+
output_metadata TEXT
|
|
113
|
+
)
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
COMPACTION_THRESHOLD = 50 # Public constant for compaction trigger threshold
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _fragment_count(catalog_dir: Path) -> int:
|
|
121
|
+
"""Count cool fragments in catalog directory."""
|
|
122
|
+
runs_dir = catalog_dir / "runs"
|
|
123
|
+
if not runs_dir.exists():
|
|
124
|
+
return 0
|
|
125
|
+
return len(list(runs_dir.glob("run_*.parquet")))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def should_compact(catalog_dir: Path) -> bool:
|
|
129
|
+
"""Return True if fragment count > COMPACTION_THRESHOLD and warm DB missing.
|
|
130
|
+
|
|
131
|
+
This indicates the user should run `bth compact` to improve query performance.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
catalog_dir: Path to catalog root
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if compaction is recommended, False otherwise
|
|
138
|
+
"""
|
|
139
|
+
if (catalog_dir / "bathos.db").exists():
|
|
140
|
+
return False
|
|
141
|
+
return _fragment_count(catalog_dir) > COMPACTION_THRESHOLD
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _apply_migrations(run: Run) -> Run:
|
|
145
|
+
"""Apply schema migrations to a run, chaining through all versions.
|
|
146
|
+
|
|
147
|
+
Migrations are applied sequentially: v0→v1→v2→...
|
|
148
|
+
Each migration updates the schema_version field, which determines
|
|
149
|
+
the next migration to apply.
|
|
150
|
+
"""
|
|
151
|
+
run_dict = asdict(run)
|
|
152
|
+
|
|
153
|
+
# Default v0 fragments (pre-schema_version) to "0"
|
|
154
|
+
current_version = run_dict.get("schema_version") or "0"
|
|
155
|
+
|
|
156
|
+
# Chain migrations until we reach the latest version
|
|
157
|
+
while current_version in MIGRATIONS:
|
|
158
|
+
run_dict = MIGRATIONS[current_version](run_dict)
|
|
159
|
+
current_version = run_dict.get("schema_version")
|
|
160
|
+
|
|
161
|
+
return Run(**run_dict)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def compact(catalog_dir: Path) -> CompactResult:
|
|
165
|
+
"""Ingest all cool fragments into bathos.db DuckDB database.
|
|
166
|
+
|
|
167
|
+
- Snapshots file list at start (ignores fragments written after snapshot)
|
|
168
|
+
- Inserts new runs into DuckDB `runs` table; skips any runs already present (keyed on `id`)
|
|
169
|
+
- Tracks warm-tier schema version in `_schema_meta` table
|
|
170
|
+
- Does NOT remove cool fragments after ingest (safe default)
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
catalog_dir: Path to catalog root (contains runs/ and bathos.db target)
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
CompactResult with ingested count, skipped count, and duration
|
|
177
|
+
"""
|
|
178
|
+
start_time = time.time()
|
|
179
|
+
|
|
180
|
+
# Read all runs from cool fragments (read_runs snapshots file list internally)
|
|
181
|
+
cool_runs = read_runs(catalog_dir)
|
|
182
|
+
|
|
183
|
+
# Open DuckDB connection
|
|
184
|
+
db_path = catalog_dir / "bathos.db"
|
|
185
|
+
con = duckdb.connect(str(db_path))
|
|
186
|
+
|
|
187
|
+
# Initialize schema meta table if it doesn't exist
|
|
188
|
+
con.execute("CREATE TABLE IF NOT EXISTS _schema_meta (key TEXT PRIMARY KEY, value TEXT)")
|
|
189
|
+
|
|
190
|
+
# Initialize runs table if it doesn't exist
|
|
191
|
+
con.execute(_RUNS_TABLE_SCHEMA)
|
|
192
|
+
|
|
193
|
+
# Track ingested and skipped counts
|
|
194
|
+
ingested = 0
|
|
195
|
+
skipped = 0
|
|
196
|
+
|
|
197
|
+
# Ingest each run
|
|
198
|
+
for run in cool_runs:
|
|
199
|
+
# Check if run already exists in DuckDB
|
|
200
|
+
existing = con.execute("SELECT id FROM runs WHERE id = ?", [run.id]).fetchall()
|
|
201
|
+
|
|
202
|
+
if existing:
|
|
203
|
+
skipped += 1
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Apply migrations if needed
|
|
207
|
+
run = _apply_migrations(run)
|
|
208
|
+
|
|
209
|
+
# Collect output metadata
|
|
210
|
+
output_metadata = []
|
|
211
|
+
if run.output_paths:
|
|
212
|
+
for output_path in run.output_paths:
|
|
213
|
+
meta = _collect_output_metadata(output_path)
|
|
214
|
+
output_metadata.append({"path": output_path, **meta})
|
|
215
|
+
|
|
216
|
+
# Serialize metadata to JSON
|
|
217
|
+
output_metadata_json = json.dumps(output_metadata) if output_metadata else "[]"
|
|
218
|
+
|
|
219
|
+
# Insert into DuckDB
|
|
220
|
+
con.execute(
|
|
221
|
+
"""
|
|
222
|
+
INSERT INTO runs (
|
|
223
|
+
id, project_slug, command, argv, git_hash, git_branch,
|
|
224
|
+
git_dirty, timestamp, duration_s, exit_code, status,
|
|
225
|
+
output_paths, tags, schema_version, slurm_job_id, hostname, metadata, outcome, output_metadata
|
|
226
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
227
|
+
""",
|
|
228
|
+
[
|
|
229
|
+
run.id,
|
|
230
|
+
run.project_slug,
|
|
231
|
+
run.command,
|
|
232
|
+
run.argv,
|
|
233
|
+
run.git_hash,
|
|
234
|
+
run.git_branch,
|
|
235
|
+
run.git_dirty,
|
|
236
|
+
run.timestamp,
|
|
237
|
+
run.duration_s,
|
|
238
|
+
run.exit_code,
|
|
239
|
+
run.status,
|
|
240
|
+
run.output_paths,
|
|
241
|
+
run.tags,
|
|
242
|
+
run.schema_version,
|
|
243
|
+
run.slurm_job_id,
|
|
244
|
+
run.hostname,
|
|
245
|
+
run.metadata,
|
|
246
|
+
None, # outcome is not set during compact
|
|
247
|
+
output_metadata_json,
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
ingested += 1
|
|
252
|
+
|
|
253
|
+
# Update schema_meta table
|
|
254
|
+
con.execute(
|
|
255
|
+
"INSERT OR REPLACE INTO _schema_meta (key, value) VALUES (?, ?)",
|
|
256
|
+
["warm_version", "2"],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Commit and close
|
|
260
|
+
con.close()
|
|
261
|
+
|
|
262
|
+
duration_s = time.time() - start_time
|
|
263
|
+
|
|
264
|
+
return CompactResult(
|
|
265
|
+
ingested=ingested,
|
|
266
|
+
skipped=skipped,
|
|
267
|
+
duration_s=duration_s,
|
|
268
|
+
)
|
bathos/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ProjectConfig:
|
|
10
|
+
slug: str
|
|
11
|
+
root: Path
|
|
12
|
+
remotes: dict[str, dict] = field(default_factory=dict)
|
|
13
|
+
slurm: dict = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_catalog_dir() -> Path:
|
|
17
|
+
return Path.home() / ".bth" / "catalog"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_project_config(start: Path = Path.cwd()) -> Path | None:
|
|
21
|
+
for directory in [start, *start.parents]:
|
|
22
|
+
candidate = directory / ".bth.toml"
|
|
23
|
+
if candidate.exists():
|
|
24
|
+
return candidate
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_project_config(path: Path) -> ProjectConfig:
|
|
29
|
+
with open(path, "rb") as f:
|
|
30
|
+
data = tomllib.load(f)
|
|
31
|
+
project = data["project"]
|
|
32
|
+
return ProjectConfig(
|
|
33
|
+
slug=project["slug"],
|
|
34
|
+
root=Path(project["root"]),
|
|
35
|
+
remotes=data.get("remotes", {}),
|
|
36
|
+
slurm=data.get("slurm", {}),
|
|
37
|
+
)
|
bathos/git.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class GitState:
|
|
10
|
+
hash: str
|
|
11
|
+
branch: str
|
|
12
|
+
dirty: bool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_UNKNOWN = GitState(hash="unknown", branch="unknown", dirty=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def capture_git_state(cwd: Path = Path.cwd()) -> GitState:
|
|
19
|
+
try:
|
|
20
|
+
hash_ = subprocess.check_output(
|
|
21
|
+
["git", "rev-parse", "HEAD"], cwd=cwd, text=True, stderr=subprocess.DEVNULL
|
|
22
|
+
).strip()
|
|
23
|
+
branch = subprocess.check_output(
|
|
24
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
25
|
+
cwd=cwd,
|
|
26
|
+
text=True,
|
|
27
|
+
stderr=subprocess.DEVNULL,
|
|
28
|
+
).strip()
|
|
29
|
+
dirty = bool(
|
|
30
|
+
subprocess.check_output(
|
|
31
|
+
["git", "status", "--porcelain"],
|
|
32
|
+
cwd=cwd,
|
|
33
|
+
text=True,
|
|
34
|
+
stderr=subprocess.DEVNULL,
|
|
35
|
+
).strip()
|
|
36
|
+
)
|
|
37
|
+
return GitState(hash=hash_, branch=branch, dirty=dirty)
|
|
38
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
39
|
+
return _UNKNOWN
|
bathos/init.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from bathos.catalog import init_catalog
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIRS = [
|
|
9
|
+
"scripts/experiments",
|
|
10
|
+
"scripts/analysis",
|
|
11
|
+
"scripts/validation",
|
|
12
|
+
"scripts/benchmarks",
|
|
13
|
+
"scripts/data",
|
|
14
|
+
"scripts/slurm",
|
|
15
|
+
"scripts/debug",
|
|
16
|
+
"scripts/explore",
|
|
17
|
+
"scripts/scratch",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_BTH_TOML_TEMPLATE = """\
|
|
21
|
+
[project]
|
|
22
|
+
slug = "{slug}"
|
|
23
|
+
root = "{root}"
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_GITIGNORE_ENTRY = "scripts/scratch/\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_env_sh_template() -> str:
|
|
30
|
+
pkg = importlib.resources.files("bathos") / "templates" / "_bth_env.sh"
|
|
31
|
+
return pkg.read_text(encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def init_project(
|
|
35
|
+
project_root: Path,
|
|
36
|
+
slug: str,
|
|
37
|
+
catalog_dir: Path,
|
|
38
|
+
remote: str | None = None,
|
|
39
|
+
slurm_partition: str | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
# Script directories
|
|
42
|
+
for d in SCRIPT_DIRS:
|
|
43
|
+
(project_root / d).mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
# .bth.toml
|
|
46
|
+
toml_path = project_root / ".bth.toml"
|
|
47
|
+
content = _BTH_TOML_TEMPLATE.format(slug=slug, root=str(project_root))
|
|
48
|
+
if remote:
|
|
49
|
+
host, remote_root = remote.split(":", 1)
|
|
50
|
+
content += f'\n[remotes.{host}]\nhost = "{host}"\nremote_root = "{remote_root}"\n'
|
|
51
|
+
if slurm_partition:
|
|
52
|
+
content += f'\n[slurm]\npartition = "{slurm_partition}"\n'
|
|
53
|
+
toml_path.write_text(content)
|
|
54
|
+
|
|
55
|
+
# scripts/slurm/_bth_env.sh
|
|
56
|
+
template = _load_env_sh_template()
|
|
57
|
+
env_sh = template.format(slug=slug, root=str(project_root), catalog_dir=str(catalog_dir))
|
|
58
|
+
(project_root / "scripts" / "slurm" / "_bth_env.sh").write_text(env_sh)
|
|
59
|
+
|
|
60
|
+
# .gitignore
|
|
61
|
+
gitignore = project_root / ".gitignore"
|
|
62
|
+
existing = gitignore.read_text() if gitignore.exists() else ""
|
|
63
|
+
if _GITIGNORE_ENTRY.strip() not in existing:
|
|
64
|
+
with open(gitignore, "a") as f:
|
|
65
|
+
if existing and not existing.endswith("\n"):
|
|
66
|
+
f.write("\n")
|
|
67
|
+
f.write(_GITIGNORE_ENTRY)
|
|
68
|
+
|
|
69
|
+
# Catalog
|
|
70
|
+
init_catalog(catalog_dir)
|