bathos 0.3.1__tar.gz → 0.4.0__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.
- {bathos-0.3.1 → bathos-0.4.0}/CLAUDE.md +5 -3
- {bathos-0.3.1 → bathos-0.4.0}/PKG-INFO +8 -1
- {bathos-0.3.1 → bathos-0.4.0}/README.md +7 -0
- {bathos-0.3.1 → bathos-0.4.0}/agent_assets/using_bathos/SKILL.md +1 -1
- {bathos-0.3.1 → bathos-0.4.0}/pyproject.toml +1 -1
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/__init__.py +1 -1
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/catalog.py +2 -2
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/cli.py +24 -2
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/compact.py +1 -1
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/config.py +2 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/mcp.py +1 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/migrate.py +56 -1
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/sync.py +23 -3
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_catalog.py +1 -1
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_cli.py +2 -2
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_cluster_workflow.py +2 -2
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_integration.py +2 -2
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_query.py +1 -1
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_remote.py +1 -1
- bathos-0.4.0/tests/test_sync_project_filter.py +212 -0
- {bathos-0.3.1 → bathos-0.4.0}/uv.lock +1 -1
- {bathos-0.3.1 → bathos-0.4.0}/.agent/workspace.id +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/.bth.toml +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/.coverage +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/.github/SECRETS.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/.github/workflows/publish.yml +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/.github/workflows/test.yml +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/.gitignore +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/.python-version +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/.readthedocs.yaml +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/CHANGELOG.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/LICENSE +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/agent_assets/docs/claude-code-integration.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/agent_assets/docs/gemini-cli-integration.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/Makefile +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/source/api.rst +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/source/architecture.rst +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/source/conf.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/source/contributing.rst +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/source/index.rst +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/source/slurm-integration.rst +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/source/user-guide.rst +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-15-bathos-v01-core.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-agent-onboarding-export.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-decorator.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-historical-migration.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-linter.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-remote-management.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-schema-versioning.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-skill-and-export-mcp.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-v02-feature-wiring.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-20-agentic-integrity-design.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-20-agentic-integrity-impl-plan.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/specs/2026-05-15-bathos-design.md +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/inventory.txt +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/main.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/praxia.toml +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/scripts/benchmarks/.bth.toml +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/scripts/experiments/.bth.toml +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/scripts/slurm/_bth_env.sh +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/archive.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/campaigns.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/checker.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/decorators.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/export.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/git.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/init.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/linter.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/new_experiment.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/prereg.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/query.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/remote.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/runner.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/schema.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/sidecar.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/sprint_audit.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/templates/_bth_env.sh +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/src/bathos/validate.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/__init__.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/conftest.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_archive.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_campaigns.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_checker.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_compact.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_config.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_decorators.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_export.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_git.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_init.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_linter.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_mcp_integration.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_mcp_server.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_migrate.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_new_experiment.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_prereg.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_runner.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_schema.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_sidecar.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_slurm_integration.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_sprint_audit.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_sync.py +0 -0
- {bathos-0.3.1 → bathos-0.4.0}/tests/test_validate.py +0 -0
|
@@ -10,9 +10,9 @@ bathos is a standalone experiment tracking CLI for a single researcher across 10
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
## Current Status (as of 2026-05-
|
|
13
|
+
## Current Status (as of 2026-05-21)
|
|
14
14
|
|
|
15
|
-
**v0.
|
|
15
|
+
**v0.4.0: complete and merged to main.** 333 tests passing.
|
|
16
16
|
|
|
17
17
|
- Full design spec: `.praxia/specs/bathos-design.md`
|
|
18
18
|
- v0.1 implementation plan: `.praxia/specs/bathos-v01-plan.md`
|
|
@@ -22,6 +22,8 @@ bathos is a standalone experiment tracking CLI for a single researcher across 10
|
|
|
22
22
|
|
|
23
23
|
**Shipped in v0.3:** Agentic integrity gate (`--agent-mode`, `--no-sidecar`, `--derived-from`), lineage tracking (`bth lineage`), campaigns (`bth campaign` subcommands + MCP tools), sprint audit (`bth sprint-audit`), Tier-2 lint checks, schema v3 with campaign/lineage/integrity fields, `--campaign` flag on `bth run`.
|
|
24
24
|
|
|
25
|
+
**Shipped in v0.4:** Per-project sync filtering — `bth sync` now pushes/pulls only the current project's runs (no cross-project contamination). Cool-tier layout changed to `runs/<slug>/run_<uuid>.parquet`. New command `bth migrate-to-project-subdirs` migrates flat catalogs. `sync_filter` config knob; `--no-project-filter` opt-out planned.
|
|
26
|
+
|
|
25
27
|
**Remaining backlog:** #136 (bth-migrate praxia workflow), #137 (global instruction portability), #142 (results management design).
|
|
26
28
|
|
|
27
29
|
---
|
|
@@ -34,7 +36,7 @@ bathos is a standalone experiment tracking CLI for a single researcher across 10
|
|
|
34
36
|
SLURM job / bth run
|
|
35
37
|
│
|
|
36
38
|
▼ atomic write-then-rename
|
|
37
|
-
cool/ ~/.bth/catalog/runs
|
|
39
|
+
cool/ ~/.bth/catalog/runs/<slug>/run_<uuid>.parquet ← minimal schema, SLURM-safe
|
|
38
40
|
│
|
|
39
41
|
bth compact (lazy on query or explicit)
|
|
40
42
|
│
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bathos
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Local-first, zero-server experiment tracking for researchers
|
|
5
5
|
Project-URL: Homepage, https://github.com/mariellerossi/bathos
|
|
6
6
|
Project-URL: Documentation, https://bathos.readthedocs.io
|
|
@@ -96,8 +96,15 @@ bth sql "SELECT project_slug, count(*) FROM runs GROUP BY 1"
|
|
|
96
96
|
- `bth lint [--project-root PATH]` — check scripts/ for naming conventions and missing sidecars
|
|
97
97
|
- `bth new-experiment <name>` — scaffold a new experiment script and sidecar
|
|
98
98
|
- `bth migrate` — upgrade cool-tier Parquet fragments to current schema version
|
|
99
|
+
- `bth migrate-to-project-subdirs [--dry-run]` — move flat catalog runs into `runs/<slug>/` subdirs (v0.4+)
|
|
99
100
|
- `bth export` — export the using-bathos skill and register MCP server
|
|
100
101
|
|
|
102
|
+
**`bth sync`** — Sync cool-tier catalog to/from cluster (v0.4+: per-project filtered)
|
|
103
|
+
- `bth sync [<remote>]` — push only this project's runs to the remote (filtered by `project_slug`)
|
|
104
|
+
- `bth sync [<remote>] --pull` — pull only this project's runs from the remote
|
|
105
|
+
- Output: `Pushed 47 runs (filtered 275 from other projects) to 'engaging' in 1.2s`
|
|
106
|
+
- Config: `sync_filter = "none"` in `.bth.toml` disables filtering (pushes all projects)
|
|
107
|
+
|
|
101
108
|
**`bth remote`** — Manage sync remotes
|
|
102
109
|
- `bth remote add <name> <url>` — add an SSH remote for catalog sync
|
|
103
110
|
- `bth remote list` — list configured remotes
|
|
@@ -60,8 +60,15 @@ bth sql "SELECT project_slug, count(*) FROM runs GROUP BY 1"
|
|
|
60
60
|
- `bth lint [--project-root PATH]` — check scripts/ for naming conventions and missing sidecars
|
|
61
61
|
- `bth new-experiment <name>` — scaffold a new experiment script and sidecar
|
|
62
62
|
- `bth migrate` — upgrade cool-tier Parquet fragments to current schema version
|
|
63
|
+
- `bth migrate-to-project-subdirs [--dry-run]` — move flat catalog runs into `runs/<slug>/` subdirs (v0.4+)
|
|
63
64
|
- `bth export` — export the using-bathos skill and register MCP server
|
|
64
65
|
|
|
66
|
+
**`bth sync`** — Sync cool-tier catalog to/from cluster (v0.4+: per-project filtered)
|
|
67
|
+
- `bth sync [<remote>]` — push only this project's runs to the remote (filtered by `project_slug`)
|
|
68
|
+
- `bth sync [<remote>] --pull` — pull only this project's runs from the remote
|
|
69
|
+
- Output: `Pushed 47 runs (filtered 275 from other projects) to 'engaging' in 1.2s`
|
|
70
|
+
- Config: `sync_filter = "none"` in `.bth.toml` disables filtering (pushes all projects)
|
|
71
|
+
|
|
65
72
|
**`bth remote`** — Manage sync remotes
|
|
66
73
|
- `bth remote add <name> <url>` — add an SSH remote for catalog sync
|
|
67
74
|
- `bth remote list` — list configured remotes
|
|
@@ -13,7 +13,7 @@ description: "Use when working in any bathos-tracked research project — experi
|
|
|
13
13
|
|
|
14
14
|
**Scope:** Single researcher, 10+ projects, SLURM cluster integration.
|
|
15
15
|
|
|
16
|
-
**Status:** ✅ v0.
|
|
16
|
+
**Status:** ✅ v0.4.0 shipped — 333 tests passing. Full CLI + MCP server available, including campaigns, agentic integrity gates, and per-project sync filtering.
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
@@ -15,7 +15,7 @@ def init_catalog(catalog_dir: Path) -> None:
|
|
|
15
15
|
|
|
16
16
|
def write_run(run: Run, catalog_dir: Path) -> None:
|
|
17
17
|
"""Write (or overwrite) a run record atomically."""
|
|
18
|
-
runs_dir = catalog_dir / "runs"
|
|
18
|
+
runs_dir = catalog_dir / "runs" / run.project_slug
|
|
19
19
|
runs_dir.mkdir(parents=True, exist_ok=True)
|
|
20
20
|
target = runs_dir / f"run_{run.id}.parquet"
|
|
21
21
|
tmp = runs_dir / f"run_{run.id}.tmp.parquet"
|
|
@@ -28,7 +28,7 @@ def read_runs(catalog_dir: Path) -> list[Run]:
|
|
|
28
28
|
runs_dir = catalog_dir / "runs"
|
|
29
29
|
if not runs_dir.exists():
|
|
30
30
|
return []
|
|
31
|
-
parquet_files = list(runs_dir.
|
|
31
|
+
parquet_files = list(runs_dir.rglob("run_*.parquet"))
|
|
32
32
|
if not parquet_files:
|
|
33
33
|
return []
|
|
34
34
|
tables = [pq.read_table(f) for f in parquet_files]
|
|
@@ -595,9 +595,10 @@ def sync(
|
|
|
595
595
|
|
|
596
596
|
try:
|
|
597
597
|
result = sync_catalog(remote, config, catalog_dir, pull=pull)
|
|
598
|
-
direction = "
|
|
598
|
+
direction = "Pulled" if pull else "Pushed"
|
|
599
|
+
filter_msg = f" (filtered {result.filtered} from other projects)" if result.filtered > 0 else ""
|
|
599
600
|
typer.echo(
|
|
600
|
-
f"{direction
|
|
601
|
+
f"{direction} {result.transferred} runs{filter_msg} to/from '{result.remote}' in {result.duration_s:.1f}s"
|
|
601
602
|
)
|
|
602
603
|
except ValueError as e:
|
|
603
604
|
typer.echo(str(e), err=True)
|
|
@@ -624,6 +625,27 @@ def migrate(
|
|
|
624
625
|
typer.echo(" Nothing to migrate.")
|
|
625
626
|
|
|
626
627
|
|
|
628
|
+
@app.command("migrate-to-project-subdirs")
|
|
629
|
+
def migrate_to_subdirs_cmd(
|
|
630
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be moved without writing"),
|
|
631
|
+
):
|
|
632
|
+
"""Move flat cool-tier run parquets into per-project subdirectories.
|
|
633
|
+
|
|
634
|
+
Reads each run's project_slug and moves it to runs/<slug>/run_<uuid>.parquet.
|
|
635
|
+
Run this on both local and remote before using per-project sync filtering.
|
|
636
|
+
"""
|
|
637
|
+
from bathos.migrate import migrate_to_project_subdirs
|
|
638
|
+
|
|
639
|
+
result = migrate_to_project_subdirs(_catalog_dir(), dry_run=dry_run)
|
|
640
|
+
action = "Would move" if dry_run else "Moved"
|
|
641
|
+
typer.echo(f"{action} {result.moved} run(s) into per-project subdirectories.")
|
|
642
|
+
if result.skipped:
|
|
643
|
+
typer.echo(f" {result.skipped} already in place (skipped).")
|
|
644
|
+
if result.by_slug:
|
|
645
|
+
for slug, count in sorted(result.by_slug.items()):
|
|
646
|
+
typer.echo(f" {slug}: {count}")
|
|
647
|
+
|
|
648
|
+
|
|
627
649
|
@app.command("sprint-audit")
|
|
628
650
|
def sprint_audit_cmd(
|
|
629
651
|
hours: int = typer.Option(24, "--hours", help="Lookback window in hours"),
|
|
@@ -180,7 +180,7 @@ def _fragment_count(catalog_dir: Path) -> int:
|
|
|
180
180
|
runs_dir = catalog_dir / "runs"
|
|
181
181
|
if not runs_dir.exists():
|
|
182
182
|
return 0
|
|
183
|
-
return len(list(runs_dir.
|
|
183
|
+
return len(list(runs_dir.rglob("run_*.parquet")))
|
|
184
184
|
|
|
185
185
|
|
|
186
186
|
def should_compact(catalog_dir: Path) -> bool:
|
|
@@ -14,6 +14,7 @@ class ProjectConfig:
|
|
|
14
14
|
catalog_dir: Path = field(default_factory=lambda: Path.home() / ".bth" / "catalog")
|
|
15
15
|
remotes: dict[str, dict] = field(default_factory=dict)
|
|
16
16
|
slurm: dict = field(default_factory=dict)
|
|
17
|
+
sync_filter: str = "project_slug"
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def default_catalog_dir() -> Path:
|
|
@@ -38,6 +39,7 @@ def load_project_config(path: Path) -> ProjectConfig:
|
|
|
38
39
|
catalog_dir=Path(project["catalog_dir"]) if "catalog_dir" in project else default_catalog_dir(),
|
|
39
40
|
remotes=data.get("remotes", {}),
|
|
40
41
|
slurm=data.get("slurm", {}),
|
|
42
|
+
sync_filter=project.get("sync_filter", "project_slug"),
|
|
41
43
|
)
|
|
42
44
|
|
|
43
45
|
|
|
@@ -24,7 +24,7 @@ def migrate_catalog(catalog_dir: Path, dry_run: bool = False) -> MigrateResult:
|
|
|
24
24
|
if not runs_dir.exists():
|
|
25
25
|
return MigrateResult(scanned=0, already_current=0, migrated=0, dry_run=dry_run)
|
|
26
26
|
|
|
27
|
-
fragments = list(runs_dir.
|
|
27
|
+
fragments = list(runs_dir.rglob("run_*.parquet"))
|
|
28
28
|
current_names = {f.name for f in COOL_SCHEMA}
|
|
29
29
|
migrated = 0
|
|
30
30
|
|
|
@@ -62,6 +62,61 @@ def migrate_catalog(catalog_dir: Path, dry_run: bool = False) -> MigrateResult:
|
|
|
62
62
|
)
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
@dataclass
|
|
66
|
+
class MigrateToSubdirsResult:
|
|
67
|
+
moved: int
|
|
68
|
+
skipped: int
|
|
69
|
+
by_slug: dict
|
|
70
|
+
dry_run: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def migrate_to_project_subdirs(catalog_dir: Path, dry_run: bool = False) -> MigrateToSubdirsResult:
|
|
74
|
+
"""Move flat cool-tier run parquets into per-project subdirectories.
|
|
75
|
+
|
|
76
|
+
Reads project_slug from each parquet and moves it to runs/<slug>/run_<uuid>.parquet.
|
|
77
|
+
Files already in a subdir are skipped. Idempotent.
|
|
78
|
+
Falls back to 'default' for parquets without a readable project_slug.
|
|
79
|
+
"""
|
|
80
|
+
from collections import Counter
|
|
81
|
+
|
|
82
|
+
runs_dir = catalog_dir / "runs"
|
|
83
|
+
if not runs_dir.exists():
|
|
84
|
+
return MigrateToSubdirsResult(moved=0, skipped=0, by_slug={}, dry_run=dry_run)
|
|
85
|
+
|
|
86
|
+
# Only consider directly-nested parquets (flat layout), not already-subdir'd ones
|
|
87
|
+
flat_files = [f for f in runs_dir.glob("run_*.parquet")]
|
|
88
|
+
|
|
89
|
+
moves = []
|
|
90
|
+
for parquet in flat_files:
|
|
91
|
+
slug = _read_project_slug(parquet) or "default"
|
|
92
|
+
dst = runs_dir / slug / parquet.name
|
|
93
|
+
moves.append((parquet, dst))
|
|
94
|
+
|
|
95
|
+
if not dry_run:
|
|
96
|
+
for src, dst in moves:
|
|
97
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
src.rename(dst)
|
|
99
|
+
|
|
100
|
+
by_slug = dict(Counter(dst.parent.name for _, dst in moves))
|
|
101
|
+
return MigrateToSubdirsResult(
|
|
102
|
+
moved=len(moves),
|
|
103
|
+
skipped=len(flat_files) - len(moves),
|
|
104
|
+
by_slug=by_slug,
|
|
105
|
+
dry_run=dry_run,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read_project_slug(parquet: Path) -> str | None:
|
|
110
|
+
"""Read project_slug from the first row of a parquet file."""
|
|
111
|
+
try:
|
|
112
|
+
tbl = pq.read_table(parquet, columns=["project_slug"])
|
|
113
|
+
if tbl.num_rows > 0:
|
|
114
|
+
return tbl["project_slug"][0].as_py()
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
65
120
|
def _default_array(arrow_type: pa.DataType, n: int) -> pa.Array:
|
|
66
121
|
if pa.types.is_string(arrow_type) or pa.types.is_large_string(arrow_type):
|
|
67
122
|
return pa.array([""] * n, type=arrow_type)
|
|
@@ -17,6 +17,7 @@ class SyncResult:
|
|
|
17
17
|
transferred: int
|
|
18
18
|
duration_s: float
|
|
19
19
|
remote: str
|
|
20
|
+
filtered: int = 0
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def sync_catalog(
|
|
@@ -45,8 +46,27 @@ def sync_catalog(
|
|
|
45
46
|
host = remote_config["host"]
|
|
46
47
|
remote_root = remote_config["remote_root"]
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
project_slug = config.slug
|
|
50
|
+
sync_filter = getattr(config, "sync_filter", "project_slug")
|
|
51
|
+
|
|
52
|
+
if sync_filter == "project_slug":
|
|
53
|
+
local_runs = catalog_dir / "runs" / project_slug
|
|
54
|
+
remote_runs = f"{host}:{remote_root}/.bth/catalog/runs/{project_slug}/"
|
|
55
|
+
# Count filtered runs (total in catalog minus this project's runs)
|
|
56
|
+
runs_root = catalog_dir / "runs"
|
|
57
|
+
if not pull and runs_root.exists():
|
|
58
|
+
total = len(list(runs_root.rglob("run_*.parquet")))
|
|
59
|
+
project_count = len(list(local_runs.glob("run_*.parquet"))) if local_runs.exists() else 0
|
|
60
|
+
filtered = total - project_count
|
|
61
|
+
else:
|
|
62
|
+
filtered = 0
|
|
63
|
+
else:
|
|
64
|
+
local_runs = catalog_dir / "runs"
|
|
65
|
+
remote_runs = f"{host}:{remote_root}/.bth/catalog/runs/"
|
|
66
|
+
filtered = 0
|
|
67
|
+
|
|
68
|
+
if not pull:
|
|
69
|
+
local_runs.mkdir(parents=True, exist_ok=True)
|
|
50
70
|
|
|
51
71
|
if pull:
|
|
52
72
|
src = remote_runs
|
|
@@ -88,7 +108,7 @@ def sync_catalog(
|
|
|
88
108
|
# rsync outputs something like "Number of regular files transferred: 42"
|
|
89
109
|
transferred = _parse_transferred_count(result.stdout)
|
|
90
110
|
|
|
91
|
-
return SyncResult(transferred=transferred, duration_s=duration_s, remote=remote_name)
|
|
111
|
+
return SyncResult(transferred=transferred, duration_s=duration_s, remote=remote_name, filtered=filtered)
|
|
92
112
|
|
|
93
113
|
|
|
94
114
|
def _parse_transferred_count(rsync_output: str) -> int:
|
|
@@ -24,7 +24,7 @@ def test_write_and_read_single_run(tmp_catalog: Path, sample_run: Run):
|
|
|
24
24
|
def test_write_creates_parquet_file(tmp_catalog: Path, sample_run: Run):
|
|
25
25
|
init_catalog(tmp_catalog)
|
|
26
26
|
write_run(sample_run, tmp_catalog)
|
|
27
|
-
files = list((tmp_catalog / "runs").
|
|
27
|
+
files = list((tmp_catalog / "runs").rglob("*.parquet"))
|
|
28
28
|
assert len(files) == 1
|
|
29
29
|
assert sample_run.id in files[0].name
|
|
30
30
|
|
|
@@ -186,8 +186,8 @@ def test_sql_allows_arbitrary_queries(tmp_path: Path, monkeypatch):
|
|
|
186
186
|
# Create a run to have Parquet files
|
|
187
187
|
runner.invoke(app, ["run", sys.executable, "--", "-c", "pass"])
|
|
188
188
|
|
|
189
|
-
# Query using read_parquet should work without warm DB
|
|
190
|
-
glob = str(catalog / "runs" / "run_*.parquet")
|
|
189
|
+
# Query using read_parquet should work without warm DB (runs are in per-project subdir)
|
|
190
|
+
glob = str(catalog / "runs" / "testproj" / "run_*.parquet")
|
|
191
191
|
result = runner.invoke(app, ["sql", f"SELECT COUNT(*) FROM read_parquet('{glob}')"])
|
|
192
192
|
assert result.exit_code == 0
|
|
193
193
|
assert "1" in result.output
|
|
@@ -119,7 +119,7 @@ remote_root = "~/projects/testproj"
|
|
|
119
119
|
write_run(run2, catalog)
|
|
120
120
|
|
|
121
121
|
# Verify we have 2 cool fragments
|
|
122
|
-
run_files = list((catalog / "runs").
|
|
122
|
+
run_files = list((catalog / "runs").rglob("run_*.parquet"))
|
|
123
123
|
assert len(run_files) == 2, f"Expected 2 run files, got {len(run_files)}"
|
|
124
124
|
|
|
125
125
|
# Step 3: Compact cool fragments → warm DuckDB
|
|
@@ -238,7 +238,7 @@ def test_cluster_workflow_with_slurm_job_id(git_repo: Path, monkeypatch):
|
|
|
238
238
|
write_run(run, catalog)
|
|
239
239
|
|
|
240
240
|
# Verify SLURM_JOB_ID is in the written fragment
|
|
241
|
-
run_files = list((catalog / "runs").
|
|
241
|
+
run_files = list((catalog / "runs").rglob("run_*.parquet"))
|
|
242
242
|
assert len(run_files) == 1
|
|
243
243
|
|
|
244
244
|
# Compact and verify slurm_job_id is preserved in warm tier
|
|
@@ -61,8 +61,8 @@ def test_full_workflow(tmp_path: Path, monkeypatch):
|
|
|
61
61
|
assert run_id in r.output
|
|
62
62
|
assert "intproj" in r.output
|
|
63
63
|
|
|
64
|
-
# 7. sql escape hatch (cool tier)
|
|
65
|
-
glob = str(catalog / "runs" / "run_*.parquet")
|
|
64
|
+
# 7. sql escape hatch (cool tier; runs are in per-project subdirs)
|
|
65
|
+
glob = str(catalog / "runs" / "intproj" / "run_*.parquet")
|
|
66
66
|
r = runner.invoke(app, ["sql", f"SELECT count(*) FROM read_parquet('{glob}')"])
|
|
67
67
|
assert r.exit_code == 0
|
|
68
68
|
assert "2" in r.output
|
|
@@ -71,7 +71,7 @@ def test_find_runs_since(populated_catalog: Path):
|
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def test_run_sql_returns_rows(populated_catalog: Path):
|
|
74
|
-
glob = str(populated_catalog / "runs" / "run_*.parquet")
|
|
74
|
+
glob = str(populated_catalog / "runs" / "**" / "run_*.parquet")
|
|
75
75
|
rows = run_sql(
|
|
76
76
|
f"SELECT project_slug, count(*) as n FROM read_parquet('{glob}') GROUP BY 1 ORDER BY 1"
|
|
77
77
|
)
|
|
@@ -615,7 +615,7 @@ class TestSyncAutoSelection:
|
|
|
615
615
|
mock_load.return_value = config
|
|
616
616
|
|
|
617
617
|
with patch("bathos.cli.sync_catalog") as mock_sync:
|
|
618
|
-
mock_sync.return_value = MagicMock(transferred=3, remote="engaging", duration_s=0.8)
|
|
618
|
+
mock_sync.return_value = MagicMock(transferred=3, remote="engaging", duration_s=0.8, filtered=0)
|
|
619
619
|
|
|
620
620
|
result = cli_runner.invoke(app, ["sync"])
|
|
621
621
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Tests for per-project sync filtering and migration to project subdirectories."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import pyarrow as pa
|
|
8
|
+
import pyarrow.parquet as pq
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from bathos.catalog import write_run
|
|
12
|
+
from bathos.config import ProjectConfig
|
|
13
|
+
from bathos.migrate import migrate_to_project_subdirs
|
|
14
|
+
from bathos.schema import Run
|
|
15
|
+
from bathos.sync import SyncResult, sync_catalog
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_run(slug: str, suffix: str = "") -> Run:
|
|
19
|
+
return Run(
|
|
20
|
+
project_slug=slug,
|
|
21
|
+
command=f"python {slug}{suffix}.py",
|
|
22
|
+
argv=["python", f"{slug}{suffix}.py"],
|
|
23
|
+
git_hash="abc123",
|
|
24
|
+
git_branch="main",
|
|
25
|
+
git_dirty=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _write_flat(runs_dir: Path, slug: str, suffix: str = "") -> Path:
|
|
30
|
+
"""Write a parquet with project_slug directly in the flat runs/ dir (pre-migration layout)."""
|
|
31
|
+
run = _make_run(slug, suffix)
|
|
32
|
+
tbl = run.to_arrow()
|
|
33
|
+
path = runs_dir / f"run_{run.id}.parquet"
|
|
34
|
+
pq.write_table(tbl, path)
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _config(tmp_path: Path, slug: str, sync_filter: str = "project_slug") -> ProjectConfig:
|
|
39
|
+
return ProjectConfig(
|
|
40
|
+
slug=slug,
|
|
41
|
+
root=tmp_path,
|
|
42
|
+
remotes={"engaging": {"host": "engaging", "remote_root": f"~/projects/{slug}"}},
|
|
43
|
+
sync_filter=sync_filter,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# 1. Sync filters by slug: only this project's subdir is pushed
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def test_sync_filters_by_slug(tmp_path: Path):
|
|
52
|
+
catalog = tmp_path / "catalog"
|
|
53
|
+
config = _config(tmp_path, "asr")
|
|
54
|
+
|
|
55
|
+
# Write runs for two projects into their respective subdirs via write_run
|
|
56
|
+
write_run(_make_run("asr"), catalog)
|
|
57
|
+
write_run(_make_run("asr", "_2"), catalog)
|
|
58
|
+
write_run(_make_run("prolix"), catalog)
|
|
59
|
+
|
|
60
|
+
with patch("bathos.sync.subprocess.run") as mock_run:
|
|
61
|
+
mock_run.return_value.returncode = 0
|
|
62
|
+
mock_run.return_value.stdout = "Number of regular files transferred: 2"
|
|
63
|
+
|
|
64
|
+
result = sync_catalog("engaging", config, catalog, pull=False)
|
|
65
|
+
|
|
66
|
+
cmd = mock_run.call_args[0][0]
|
|
67
|
+
# Source should be the project-slug subdir
|
|
68
|
+
assert any(f"runs/asr/" in str(a) for a in cmd), f"Expected runs/asr/ in cmd: {cmd}"
|
|
69
|
+
# Should NOT reference prolix
|
|
70
|
+
assert not any("prolix" in str(a) for a in cmd)
|
|
71
|
+
# Filtered count: 1 prolix run
|
|
72
|
+
assert result.filtered == 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# 2. sync_filter="none" preserves current flat behavior
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def test_sync_no_filter_mode_preserves_current_behavior(tmp_path: Path):
|
|
80
|
+
catalog = tmp_path / "catalog"
|
|
81
|
+
config = _config(tmp_path, "asr", sync_filter="none")
|
|
82
|
+
|
|
83
|
+
write_run(_make_run("asr"), catalog)
|
|
84
|
+
write_run(_make_run("prolix"), catalog)
|
|
85
|
+
|
|
86
|
+
with patch("bathos.sync.subprocess.run") as mock_run:
|
|
87
|
+
mock_run.return_value.returncode = 0
|
|
88
|
+
mock_run.return_value.stdout = ""
|
|
89
|
+
|
|
90
|
+
result = sync_catalog("engaging", config, catalog, pull=False)
|
|
91
|
+
|
|
92
|
+
cmd = mock_run.call_args[0][0]
|
|
93
|
+
# Source should be the flat runs/ dir (no slug subdir)
|
|
94
|
+
assert any(str(catalog / "runs") + "/" in str(a) for a in cmd), f"Expected flat runs/ in cmd: {cmd}"
|
|
95
|
+
assert not any("asr" in str(a) and "runs/asr" in str(a) for a in cmd)
|
|
96
|
+
assert result.filtered == 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# 3. Pull uses project-slug subdir as destination
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def test_sync_pull_targets_project_subdir(tmp_path: Path):
|
|
104
|
+
catalog = tmp_path / "catalog"
|
|
105
|
+
config = _config(tmp_path, "asr")
|
|
106
|
+
|
|
107
|
+
with patch("bathos.sync.subprocess.run") as mock_run:
|
|
108
|
+
mock_run.return_value.returncode = 0
|
|
109
|
+
mock_run.return_value.stdout = ""
|
|
110
|
+
|
|
111
|
+
sync_catalog("engaging", config, catalog, pull=True)
|
|
112
|
+
|
|
113
|
+
cmd = mock_run.call_args[0][0]
|
|
114
|
+
# Remote source should include the slug subdir
|
|
115
|
+
assert any("runs/asr/" in str(a) for a in cmd), f"Expected runs/asr/ in cmd: {cmd}"
|
|
116
|
+
# Local destination should also include the slug subdir
|
|
117
|
+
assert any(str(catalog / "runs" / "asr") in str(a) for a in cmd)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# 4. migrate_to_project_subdirs --dry-run reports counts but doesn't move
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def test_migrate_to_project_subdirs_dry_run(tmp_path: Path):
|
|
125
|
+
runs_dir = tmp_path / "runs"
|
|
126
|
+
runs_dir.mkdir()
|
|
127
|
+
_write_flat(runs_dir, "asr")
|
|
128
|
+
_write_flat(runs_dir, "prolix")
|
|
129
|
+
|
|
130
|
+
result = migrate_to_project_subdirs(tmp_path, dry_run=True)
|
|
131
|
+
|
|
132
|
+
assert result.moved == 2
|
|
133
|
+
assert result.dry_run is True
|
|
134
|
+
# Files should still be in the flat dir
|
|
135
|
+
assert len(list(runs_dir.glob("run_*.parquet"))) == 2
|
|
136
|
+
assert not (runs_dir / "asr").exists()
|
|
137
|
+
assert not (runs_dir / "prolix").exists()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# 5. migrate_to_project_subdirs is idempotent
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def test_migrate_to_project_subdirs_idempotent(tmp_path: Path):
|
|
145
|
+
runs_dir = tmp_path / "runs"
|
|
146
|
+
runs_dir.mkdir()
|
|
147
|
+
_write_flat(runs_dir, "asr")
|
|
148
|
+
_write_flat(runs_dir, "prolix")
|
|
149
|
+
|
|
150
|
+
result1 = migrate_to_project_subdirs(tmp_path, dry_run=False)
|
|
151
|
+
assert result1.moved == 2
|
|
152
|
+
|
|
153
|
+
# Second call: no flat files left → nothing to move
|
|
154
|
+
result2 = migrate_to_project_subdirs(tmp_path, dry_run=False)
|
|
155
|
+
assert result2.moved == 0
|
|
156
|
+
|
|
157
|
+
# Files are in the right places
|
|
158
|
+
assert len(list((runs_dir / "asr").glob("run_*.parquet"))) == 1
|
|
159
|
+
assert len(list((runs_dir / "prolix").glob("run_*.parquet"))) == 1
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# 6. Migration fallback for parquets without a project_slug
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def test_migrate_fallback_for_runs_without_slug(tmp_path: Path):
|
|
167
|
+
runs_dir = tmp_path / "runs"
|
|
168
|
+
runs_dir.mkdir()
|
|
169
|
+
|
|
170
|
+
# Write a parquet with no project_slug column
|
|
171
|
+
tbl = pa.table({"id": ["abc-123"], "command": ["python test.py"]})
|
|
172
|
+
path = runs_dir / "run_abc-123.parquet"
|
|
173
|
+
pq.write_table(tbl, path)
|
|
174
|
+
|
|
175
|
+
result = migrate_to_project_subdirs(tmp_path, dry_run=False)
|
|
176
|
+
|
|
177
|
+
assert result.moved == 1
|
|
178
|
+
assert result.by_slug == {"default": 1}
|
|
179
|
+
assert (runs_dir / "default" / "run_abc-123.parquet").exists()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# 7. bth ls finds runs after migration (read_runs uses rglob)
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def test_ls_finds_runs_in_subdirs(tmp_path: Path):
|
|
187
|
+
from bathos.catalog import read_runs
|
|
188
|
+
|
|
189
|
+
catalog = tmp_path / "catalog"
|
|
190
|
+
run = _make_run("asr")
|
|
191
|
+
write_run(run, catalog) # now writes to catalog/runs/asr/
|
|
192
|
+
|
|
193
|
+
runs = read_runs(catalog)
|
|
194
|
+
assert len(runs) == 1
|
|
195
|
+
assert runs[0].project_slug == "asr"
|
|
196
|
+
assert runs[0].id == run.id
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# 8. bth run writes new runs under runs/<slug>/
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def test_run_writes_to_project_subdir(tmp_path: Path):
|
|
204
|
+
from bathos.catalog import write_run
|
|
205
|
+
|
|
206
|
+
catalog = tmp_path / "catalog"
|
|
207
|
+
run = _make_run("myproject")
|
|
208
|
+
write_run(run, catalog)
|
|
209
|
+
|
|
210
|
+
# Should be under runs/myproject/, NOT in runs/ directly
|
|
211
|
+
assert (catalog / "runs" / "myproject" / f"run_{run.id}.parquet").exists()
|
|
212
|
+
assert not (catalog / "runs" / f"run_{run.id}.parquet").exists()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-20-agentic-integrity-impl-plan.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|