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