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.
Files changed (102) hide show
  1. {bathos-0.3.1 → bathos-0.4.0}/CLAUDE.md +5 -3
  2. {bathos-0.3.1 → bathos-0.4.0}/PKG-INFO +8 -1
  3. {bathos-0.3.1 → bathos-0.4.0}/README.md +7 -0
  4. {bathos-0.3.1 → bathos-0.4.0}/agent_assets/using_bathos/SKILL.md +1 -1
  5. {bathos-0.3.1 → bathos-0.4.0}/pyproject.toml +1 -1
  6. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/__init__.py +1 -1
  7. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/catalog.py +2 -2
  8. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/cli.py +24 -2
  9. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/compact.py +1 -1
  10. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/config.py +2 -0
  11. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/mcp.py +1 -0
  12. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/migrate.py +56 -1
  13. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/sync.py +23 -3
  14. {bathos-0.3.1 → bathos-0.4.0}/tests/test_catalog.py +1 -1
  15. {bathos-0.3.1 → bathos-0.4.0}/tests/test_cli.py +2 -2
  16. {bathos-0.3.1 → bathos-0.4.0}/tests/test_cluster_workflow.py +2 -2
  17. {bathos-0.3.1 → bathos-0.4.0}/tests/test_integration.py +2 -2
  18. {bathos-0.3.1 → bathos-0.4.0}/tests/test_query.py +1 -1
  19. {bathos-0.3.1 → bathos-0.4.0}/tests/test_remote.py +1 -1
  20. bathos-0.4.0/tests/test_sync_project_filter.py +212 -0
  21. {bathos-0.3.1 → bathos-0.4.0}/uv.lock +1 -1
  22. {bathos-0.3.1 → bathos-0.4.0}/.agent/workspace.id +0 -0
  23. {bathos-0.3.1 → bathos-0.4.0}/.bth.toml +0 -0
  24. {bathos-0.3.1 → bathos-0.4.0}/.coverage +0 -0
  25. {bathos-0.3.1 → bathos-0.4.0}/.github/SECRETS.md +0 -0
  26. {bathos-0.3.1 → bathos-0.4.0}/.github/workflows/publish.yml +0 -0
  27. {bathos-0.3.1 → bathos-0.4.0}/.github/workflows/test.yml +0 -0
  28. {bathos-0.3.1 → bathos-0.4.0}/.gitignore +0 -0
  29. {bathos-0.3.1 → bathos-0.4.0}/.python-version +0 -0
  30. {bathos-0.3.1 → bathos-0.4.0}/.readthedocs.yaml +0 -0
  31. {bathos-0.3.1 → bathos-0.4.0}/CHANGELOG.md +0 -0
  32. {bathos-0.3.1 → bathos-0.4.0}/LICENSE +0 -0
  33. {bathos-0.3.1 → bathos-0.4.0}/agent_assets/docs/claude-code-integration.md +0 -0
  34. {bathos-0.3.1 → bathos-0.4.0}/agent_assets/docs/gemini-cli-integration.md +0 -0
  35. {bathos-0.3.1 → bathos-0.4.0}/docs/Makefile +0 -0
  36. {bathos-0.3.1 → bathos-0.4.0}/docs/source/api.rst +0 -0
  37. {bathos-0.3.1 → bathos-0.4.0}/docs/source/architecture.rst +0 -0
  38. {bathos-0.3.1 → bathos-0.4.0}/docs/source/conf.py +0 -0
  39. {bathos-0.3.1 → bathos-0.4.0}/docs/source/contributing.rst +0 -0
  40. {bathos-0.3.1 → bathos-0.4.0}/docs/source/index.rst +0 -0
  41. {bathos-0.3.1 → bathos-0.4.0}/docs/source/slurm-integration.rst +0 -0
  42. {bathos-0.3.1 → bathos-0.4.0}/docs/source/user-guide.rst +0 -0
  43. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-15-bathos-v01-core.md +0 -0
  44. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-agent-onboarding-export.md +0 -0
  45. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-decorator.md +0 -0
  46. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-historical-migration.md +0 -0
  47. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-linter.md +0 -0
  48. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-remote-management.md +0 -0
  49. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-schema-versioning.md +0 -0
  50. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-skill-and-export-mcp.md +0 -0
  51. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-19-v02-feature-wiring.md +0 -0
  52. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-20-agentic-integrity-design.md +0 -0
  53. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/plans/2026-05-20-agentic-integrity-impl-plan.md +0 -0
  54. {bathos-0.3.1 → bathos-0.4.0}/docs/superpowers/specs/2026-05-15-bathos-design.md +0 -0
  55. {bathos-0.3.1 → bathos-0.4.0}/inventory.txt +0 -0
  56. {bathos-0.3.1 → bathos-0.4.0}/main.py +0 -0
  57. {bathos-0.3.1 → bathos-0.4.0}/praxia.toml +0 -0
  58. {bathos-0.3.1 → bathos-0.4.0}/scripts/benchmarks/.bth.toml +0 -0
  59. {bathos-0.3.1 → bathos-0.4.0}/scripts/experiments/.bth.toml +0 -0
  60. {bathos-0.3.1 → bathos-0.4.0}/scripts/slurm/_bth_env.sh +0 -0
  61. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/archive.py +0 -0
  62. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/campaigns.py +0 -0
  63. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/checker.py +0 -0
  64. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/decorators.py +0 -0
  65. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/export.py +0 -0
  66. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/git.py +0 -0
  67. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/init.py +0 -0
  68. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/linter.py +0 -0
  69. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/new_experiment.py +0 -0
  70. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/prereg.py +0 -0
  71. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/query.py +0 -0
  72. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/remote.py +0 -0
  73. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/runner.py +0 -0
  74. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/schema.py +0 -0
  75. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/sidecar.py +0 -0
  76. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/sprint_audit.py +0 -0
  77. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/templates/_bth_env.sh +0 -0
  78. {bathos-0.3.1 → bathos-0.4.0}/src/bathos/validate.py +0 -0
  79. {bathos-0.3.1 → bathos-0.4.0}/tests/__init__.py +0 -0
  80. {bathos-0.3.1 → bathos-0.4.0}/tests/conftest.py +0 -0
  81. {bathos-0.3.1 → bathos-0.4.0}/tests/test_archive.py +0 -0
  82. {bathos-0.3.1 → bathos-0.4.0}/tests/test_campaigns.py +0 -0
  83. {bathos-0.3.1 → bathos-0.4.0}/tests/test_checker.py +0 -0
  84. {bathos-0.3.1 → bathos-0.4.0}/tests/test_compact.py +0 -0
  85. {bathos-0.3.1 → bathos-0.4.0}/tests/test_config.py +0 -0
  86. {bathos-0.3.1 → bathos-0.4.0}/tests/test_decorators.py +0 -0
  87. {bathos-0.3.1 → bathos-0.4.0}/tests/test_export.py +0 -0
  88. {bathos-0.3.1 → bathos-0.4.0}/tests/test_git.py +0 -0
  89. {bathos-0.3.1 → bathos-0.4.0}/tests/test_init.py +0 -0
  90. {bathos-0.3.1 → bathos-0.4.0}/tests/test_linter.py +0 -0
  91. {bathos-0.3.1 → bathos-0.4.0}/tests/test_mcp_integration.py +0 -0
  92. {bathos-0.3.1 → bathos-0.4.0}/tests/test_mcp_server.py +0 -0
  93. {bathos-0.3.1 → bathos-0.4.0}/tests/test_migrate.py +0 -0
  94. {bathos-0.3.1 → bathos-0.4.0}/tests/test_new_experiment.py +0 -0
  95. {bathos-0.3.1 → bathos-0.4.0}/tests/test_prereg.py +0 -0
  96. {bathos-0.3.1 → bathos-0.4.0}/tests/test_runner.py +0 -0
  97. {bathos-0.3.1 → bathos-0.4.0}/tests/test_schema.py +0 -0
  98. {bathos-0.3.1 → bathos-0.4.0}/tests/test_sidecar.py +0 -0
  99. {bathos-0.3.1 → bathos-0.4.0}/tests/test_slurm_integration.py +0 -0
  100. {bathos-0.3.1 → bathos-0.4.0}/tests/test_sprint_audit.py +0 -0
  101. {bathos-0.3.1 → bathos-0.4.0}/tests/test_sync.py +0 -0
  102. {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-20)
13
+ ## Current Status (as of 2026-05-21)
14
14
 
15
- **v0.3.0: complete and merged to main.** 254 tests passing.
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/run_<uuid>.parquet ← minimal schema, SLURM-safe
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.1
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.3.0 shipped — 254 tests passing. Full CLI + MCP server available, including campaigns and agentic integrity gates.
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bathos"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Local-first, zero-server experiment tracking for researchers"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.0"
1
+ __version__ = "0.4.0"
2
2
 
3
3
  from bathos.decorators import experiment
4
4
 
@@ -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.glob("run_*.parquet"))
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 = "pulled" if pull else "pushed"
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.capitalize()} {result.transferred} runs to/from '{result.remote}' in {result.duration_s:.1f}s"
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.glob("run_*.parquet")))
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
 
@@ -316,6 +316,7 @@ def sync_tool(
316
316
  "transferred": result.transferred,
317
317
  "duration_s": result.duration_s,
318
318
  "remote": result.remote,
319
+ "filtered": result.filtered,
319
320
  }
320
321
  return json.dumps(result_dict, indent=2)
321
322
  except Exception as e:
@@ -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.glob("run_*.parquet"))
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
- local_runs = catalog_dir / "runs"
49
- remote_runs = f"{host}:{remote_root}/.bth/catalog/runs/"
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").glob("*.parquet"))
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").glob("run_*.parquet"))
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").glob("run_*.parquet"))
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()
@@ -57,7 +57,7 @@ wheels = [
57
57
 
58
58
  [[package]]
59
59
  name = "bathos"
60
- version = "0.3.1"
60
+ version = "0.4.0"
61
61
  source = { editable = "." }
62
62
  dependencies = [
63
63
  { name = "duckdb" },
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
File without changes
File without changes