Skip to content

Commit 6b43cc8

Browse files
committed
save runbook snapshot
1 parent be34b52 commit 6b43cc8

File tree

15 files changed

+766
-24
lines changed

15 files changed

+766
-24
lines changed

docs/.mkdocs-metaxy/src/mkdocs_metaxy/examples/core.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- Loading runbooks from .example.yaml files
55
- Applying patches to show code evolution
66
- Reading source files at different stages
7+
- Loading saved execution results from .example.result.json files
78
"""
89

910
from __future__ import annotations
@@ -13,7 +14,7 @@
1314
from pathlib import Path
1415
from typing import Any
1516

16-
from metaxy._testing import Runbook
17+
from metaxy._testing import Runbook, SavedRunbookResult
1718

1819

1920
class RunbookLoader:
@@ -260,3 +261,26 @@ def get_patch_snapshots(
260261
"""
261262
runbook = self.load_runbook(example_name)
262263
return runbook.get_patch_snapshots()
264+
265+
def load_execution_result(self, example_name: str) -> SavedRunbookResult:
266+
"""Load saved execution result from .example.result.json file.
267+
268+
Args:
269+
example_name: Name of the example.
270+
271+
Returns:
272+
Parsed SavedRunbookResult instance.
273+
274+
Raises:
275+
FileNotFoundError: If result file doesn't exist.
276+
"""
277+
example_dir = self.get_example_dir(example_name)
278+
result_path = example_dir / ".example.result.json"
279+
280+
if not result_path.exists():
281+
raise FileNotFoundError(
282+
f"Execution result not found: {result_path}. "
283+
f"Run tests to generate: pytest tests/examples/test_example_snapshots.py"
284+
)
285+
286+
return SavedRunbookResult.from_json_file(result_path)

docs/.mkdocs-metaxy/src/mkdocs_metaxy/examples/markdown_ext.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@
2121
example: recompute
2222
path: patches/01_update_parent_algorithm.patch
2323
:::
24+
25+
::: metaxy-example output
26+
example: recompute
27+
scenario: "Initial pipeline run"
28+
step: "run_pipeline"
29+
event_type: command
30+
:::
2431
"""
2532

2633
from __future__ import annotations
@@ -212,6 +219,8 @@ def _process_directive(self, directive_type: str, content: str) -> str:
212219
return self._render_file(example_name, params)
213220
if directive_type == "patch":
214221
return self._render_patch(example_name, params)
222+
if directive_type == "output":
223+
return self._render_output(example_name, params)
215224
raise ValueError(f"Unknown directive type: {directive_type}")
216225

217226
def _render_scenarios(self, example_name: str) -> str:
@@ -334,6 +343,74 @@ def _render_patch(self, example_name: str, params: dict[str, Any]) -> str:
334343
hl_lines=None,
335344
)
336345

346+
def _render_output(self, example_name: str, params: dict[str, Any]) -> str:
347+
"""Render execution output from saved result.
348+
349+
Args:
350+
example_name: Name of the example.
351+
params: Directive parameters (scenario, step, event_type, etc.).
352+
353+
Returns:
354+
Markdown string with execution output.
355+
356+
Raises:
357+
ValueError: If required parameters are missing or invalid.
358+
"""
359+
from metaxy._testing import CommandExecuted, GraphPushed, PatchApplied
360+
361+
# Load execution result
362+
try:
363+
result = self.loader.load_execution_result(example_name)
364+
except FileNotFoundError as e:
365+
return self.renderer.render_error(
366+
f"Execution result not found for example '{example_name}'",
367+
details=str(e),
368+
)
369+
370+
# Filter events by scenario if specified
371+
scenario_name = params.get("scenario")
372+
events = result.execution_state.events
373+
if scenario_name:
374+
events = [e for e in events if e.scenario_name == scenario_name]
375+
376+
# Filter by step name if specified
377+
step_name = params.get("step")
378+
if step_name:
379+
events = [e for e in events if e.step_name == step_name]
380+
381+
# Filter by event type if specified
382+
event_type = params.get("event_type")
383+
if event_type:
384+
if event_type == "command":
385+
events = [e for e in events if isinstance(e, CommandExecuted)]
386+
elif event_type == "patch":
387+
events = [e for e in events if isinstance(e, PatchApplied)]
388+
elif event_type == "graph_push":
389+
events = [e for e in events if isinstance(e, GraphPushed)]
390+
else:
391+
raise ValueError(f"Unknown event_type: {event_type}")
392+
393+
# Render all matching events
394+
md_parts = []
395+
for event in events:
396+
if isinstance(event, CommandExecuted):
397+
show_command = params.get("show_command", True)
398+
md_parts.append(
399+
self.renderer.render_command_output(event, show_command)
400+
)
401+
elif isinstance(event, PatchApplied):
402+
md_parts.append(self.renderer.render_patch_applied(event))
403+
elif isinstance(event, GraphPushed):
404+
md_parts.append(self.renderer.render_graph_pushed(event))
405+
406+
if not md_parts:
407+
return self.renderer.render_error(
408+
"No events matched the specified criteria",
409+
details=f"scenario={scenario_name}, step={step_name}, event_type={event_type}",
410+
)
411+
412+
return "\n".join(md_parts)
413+
337414

338415
class MetaxyExamplesExtension(Extension):
339416
"""Markdown extension for Metaxy examples."""

docs/.mkdocs-metaxy/src/mkdocs_metaxy/examples/renderer.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
- Scenario lists with descriptions
55
- Source code in markdown code blocks
66
- Diff patches in markdown code blocks
7+
- Execution events (commands, patches, graph pushes)
78
"""
89

910
from __future__ import annotations
1011

1112
from typing import Any
1213

14+
from metaxy._testing import CommandExecuted, GraphPushed, PatchApplied
15+
1316

1417
class ExampleRenderer:
1518
"""Renderer for Metaxy example content."""
@@ -268,3 +271,75 @@ def render_graph_diff(
268271

269272
except Exception:
270273
return None
274+
275+
def render_command_output(
276+
self, event: CommandExecuted, show_command: bool = True
277+
) -> str:
278+
"""Render command execution output as markdown.
279+
280+
Args:
281+
event: CommandExecuted event from execution state.
282+
show_command: Whether to show the command that was executed.
283+
284+
Returns:
285+
Markdown string with command output.
286+
"""
287+
md_parts = []
288+
289+
if show_command:
290+
md_parts.append(f"```shell\n$ {event.command}\n```")
291+
md_parts.append("")
292+
293+
# Show stdout if present
294+
if event.stdout:
295+
md_parts.append("```")
296+
md_parts.append(event.stdout.rstrip())
297+
md_parts.append("```")
298+
md_parts.append("")
299+
300+
# Show stderr if present (as a warning admonition)
301+
if event.stderr:
302+
md_parts.append('!!! warning "Warnings/Errors"')
303+
md_parts.append(" ```")
304+
for line in event.stderr.rstrip().split("\n"):
305+
md_parts.append(f" {line}")
306+
md_parts.append(" ```")
307+
md_parts.append("")
308+
309+
return "\n".join(md_parts)
310+
311+
def render_patch_applied(self, event: PatchApplied) -> str:
312+
"""Render patch application event as markdown.
313+
314+
Args:
315+
event: PatchApplied event from execution state.
316+
317+
Returns:
318+
Markdown string describing the patch application.
319+
"""
320+
md_parts = []
321+
322+
md_parts.append(f"**Applied patch:** `{event.patch_path}`")
323+
md_parts.append("")
324+
325+
if event.before_snapshot and event.after_snapshot:
326+
before_short = event.before_snapshot[:8]
327+
after_short = event.after_snapshot[:8]
328+
md_parts.append(
329+
f"Graph snapshot changed: `{before_short}...` → `{after_short}...`"
330+
)
331+
md_parts.append("")
332+
333+
return "\n".join(md_parts)
334+
335+
def render_graph_pushed(self, event: GraphPushed) -> str:
336+
"""Render graph push event as markdown.
337+
338+
Args:
339+
event: GraphPushed event from execution state.
340+
341+
Returns:
342+
Markdown string describing the graph push.
343+
"""
344+
snapshot_short = event.snapshot_version[:8]
345+
return f"**Graph snapshot recorded:** `{snapshot_short}...`\n\n"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"runbook_name": "One-to-Many Expansion Example",
3+
"package_name": "example_one_to_many",
4+
"description": "Demonstrates 1:N expansion relationships and field-level provenance tracking",
5+
"execution_state": {
6+
"events": [
7+
{
8+
"type": "command_executed",
9+
"command": "python pipeline.py",
10+
"returncode": 0,
11+
"stdout": "Found 3 new videos\nFound 3 videos and 0 videos that need chunking\nProcessing video: {'video_id': 1, 'path': 'video1.mp4', 'metaxy_provenance_by_field__video_raw': {'audio': 'v1', 'frames': 'v1'}, 'metaxy_provenance__video_raw': 'c0e1a00f0a117623634caf2ae3bca98d', 'metaxy_provenance_by_field': {'audio': '4d4f04548ded52073a3e1acdc174119b', 'frames': '7543ba636a8491695e298d97b6f5d76e'}, 'metaxy_provenance': 'b70689cc645b36a1043cfac8ac279339'}\nWriting 5 chunks for video 1\nProcessing video: {'video_id': 2, 'path': 'video2.mp4', 'metaxy_provenance_by_field__video_raw': {'audio': 'v2', 'frames': 'v2'}, 'metaxy_provenance__video_raw': '6086bb90a046bc4b8a66aae64458f905', 'metaxy_provenance_by_field': {'audio': 'a79c4b1dd347aa59a478b59db84fb501', 'frames': '141f827b4c92e423b34cdec12a517c1b'}, 'metaxy_provenance': '727b940f16e01d887661376f5b172591'}\nWriting 3 chunks for video 2\nProcessing video: {'video_id': 3, 'path': 'video3.mp4', 'metaxy_provenance_by_field__video_raw': {'audio': 'v3', 'frames': 'v3'}, 'metaxy_provenance__video_raw': 'ba24e4a269e9b58c00414bc3dd8ca9f5', 'metaxy_provenance_by_field': {'audio': 'd7bb40cd544c105f2e2fbaccf6538b8d', 'frames': 'b09c343e918fff13153559bb0a09f82d'}, 'metaxy_provenance': '256503a3e5370c25696f88e2ac802d46'}\nWriting 3 chunks for video 3\nFound 11 video chunks and 0 video chunks that need face recognition\nWriting face recognition results for 11 chunks\n",
12+
"stderr": "/home/dan/code/github/anam-org/metaxy/examples/example-one-to-many/pipeline.py:116: UserWarning: AUTO_CREATE_TABLES is enabled for IbisMetadataStore(backend=duckdb) - do not use in production! Use proper database migration tools like Alembic for production deployments.\n main()\nFeature video/raw: samples parameter is Polars-backed but store uses native SQL backend. Materializing current metadata to Polars for diff comparison. For better performance, consider using samples with backend matching the store's backend.\n/home/dan/code/github/anam-org/metaxy/examples/example-one-to-many/pipeline.py:116: UserWarning: AUTO_CREATE_TABLES is enabled for IbisMetadataStore(backend=duckdb) - do not use in production! Use proper database migration tools like Alembic for production deployments.\n main()\n",
13+
"timestamp": "2025-11-11T20:01:21.702103",
14+
"scenario_name": "Initial pipeline run",
15+
"step_name": "initial_run"
16+
},
17+
{
18+
"type": "command_executed",
19+
"command": "python pipeline.py",
20+
"returncode": 0,
21+
"stdout": "Found 0 videos and 0 videos that need chunking\nFound 0 video chunks and 0 video chunks that need face recognition\n",
22+
"stderr": "/home/dan/code/github/anam-org/metaxy/examples/example-one-to-many/pipeline.py:116: UserWarning: AUTO_CREATE_TABLES is enabled for IbisMetadataStore(backend=duckdb) - do not use in production! Use proper database migration tools like Alembic for production deployments.\n main()\nFeature video/raw: samples parameter is Polars-backed but store uses native SQL backend. Materializing current metadata to Polars for diff comparison. For better performance, consider using samples with backend matching the store's backend.\n/home/dan/code/github/anam-org/metaxy/examples/example-one-to-many/pipeline.py:116: UserWarning: AUTO_CREATE_TABLES is enabled for IbisMetadataStore(backend=duckdb) - do not use in production! Use proper database migration tools like Alembic for production deployments.\n main()\n",
23+
"timestamp": "2025-11-11T20:01:23.193091",
24+
"scenario_name": "Idempotent rerun",
25+
"step_name": "idempotent_run"
26+
},
27+
{
28+
"type": "patch_applied",
29+
"patch_path": "patches/01_update_video_code_version.patch",
30+
"before_snapshot": null,
31+
"after_snapshot": null,
32+
"timestamp": "2025-11-11T20:01:24.386038",
33+
"scenario_name": "Code change - audio field only",
34+
"step_name": "update_audio_version"
35+
},
36+
{
37+
"type": "command_executed",
38+
"command": "python pipeline.py",
39+
"returncode": 0,
40+
"stdout": "Found 3 new videos\nFound 3 videos and 0 videos that need chunking\nProcessing video: {'video_id': 1, 'path': 'video1.mp4', 'metaxy_provenance_by_field__video_raw': {'audio': 'v1', 'frames': 'v1'}, 'metaxy_provenance__video_raw': 'c0e1a00f0a117623634caf2ae3bca98d', 'metaxy_provenance_by_field': {'audio': '4d4f04548ded52073a3e1acdc174119b', 'frames': '7543ba636a8491695e298d97b6f5d76e'}, 'metaxy_provenance': 'b70689cc645b36a1043cfac8ac279339'}\nWriting 5 chunks for video 1\nProcessing video: {'video_id': 2, 'path': 'video2.mp4', 'metaxy_provenance_by_field__video_raw': {'audio': 'v2', 'frames': 'v2'}, 'metaxy_provenance__video_raw': '6086bb90a046bc4b8a66aae64458f905', 'metaxy_provenance_by_field': {'audio': 'a79c4b1dd347aa59a478b59db84fb501', 'frames': '141f827b4c92e423b34cdec12a517c1b'}, 'metaxy_provenance': '727b940f16e01d887661376f5b172591'}\nWriting 3 chunks for video 2\nProcessing video: {'video_id': 3, 'path': 'video3.mp4', 'metaxy_provenance_by_field__video_raw': {'audio': 'v3', 'frames': 'v3'}, 'metaxy_provenance__video_raw': 'ba24e4a269e9b58c00414bc3dd8ca9f5', 'metaxy_provenance_by_field': {'audio': 'd7bb40cd544c105f2e2fbaccf6538b8d', 'frames': 'b09c343e918fff13153559bb0a09f82d'}, 'metaxy_provenance': '256503a3e5370c25696f88e2ac802d46'}\nWriting 3 chunks for video 3\nFound 0 video chunks and 0 video chunks that need face recognition\n",
41+
"stderr": "/home/dan/code/github/anam-org/metaxy/examples/example-one-to-many/pipeline.py:116: UserWarning: AUTO_CREATE_TABLES is enabled for IbisMetadataStore(backend=duckdb) - do not use in production! Use proper database migration tools like Alembic for production deployments.\n main()\nFeature video/raw: samples parameter is Polars-backed but store uses native SQL backend. Materializing current metadata to Polars for diff comparison. For better performance, consider using samples with backend matching the store's backend.\n/home/dan/code/github/anam-org/metaxy/examples/example-one-to-many/pipeline.py:116: UserWarning: AUTO_CREATE_TABLES is enabled for IbisMetadataStore(backend=duckdb) - do not use in production! Use proper database migration tools like Alembic for production deployments.\n main()\n",
42+
"timestamp": "2025-11-11T20:01:25.969737",
43+
"scenario_name": "Code change - audio field only",
44+
"step_name": "recompute_after_audio_change"
45+
},
46+
{
47+
"type": "command_executed",
48+
"command": "python pipeline.py",
49+
"returncode": 0,
50+
"stdout": "Found 0 videos and 0 videos that need chunking\nFound 0 video chunks and 0 video chunks that need face recognition\n",
51+
"stderr": "/home/dan/code/github/anam-org/metaxy/examples/example-one-to-many/pipeline.py:116: UserWarning: AUTO_CREATE_TABLES is enabled for IbisMetadataStore(backend=duckdb) - do not use in production! Use proper database migration tools like Alembic for production deployments.\n main()\nFeature video/raw: samples parameter is Polars-backed but store uses native SQL backend. Materializing current metadata to Polars for diff comparison. For better performance, consider using samples with backend matching the store's backend.\n/home/dan/code/github/anam-org/metaxy/examples/example-one-to-many/pipeline.py:116: UserWarning: AUTO_CREATE_TABLES is enabled for IbisMetadataStore(backend=duckdb) - do not use in production! Use proper database migration tools like Alembic for production deployments.\n main()\n",
52+
"timestamp": "2025-11-11T20:01:27.463343",
53+
"scenario_name": "Final idempotent check",
54+
"step_name": "final_idempotent_check"
55+
}
56+
]
57+
}
58+
}

examples/example-one-to-many/.example.yaml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ scenarios:
66
- name: "Initial pipeline run"
77
description: "First execution creates videos, chunks, and face recognition"
88
steps:
9-
- type: "run_command"
9+
- name: "initial_run"
10+
type: "run_command"
1011
command: "python pipeline.py"
1112
description: "Run pipeline to create videos, chunks, and face recognition"
1213
capture_output: true
@@ -26,7 +27,8 @@ scenarios:
2627
- name: "Idempotent rerun"
2728
description: "Second execution should detect no changes"
2829
steps:
29-
- type: "run_command"
30+
- name: "idempotent_run"
31+
type: "run_command"
3032
command: "python pipeline.py"
3133
description: "Rerun pipeline without any code changes"
3234
capture_output: true
@@ -42,11 +44,13 @@ scenarios:
4244
- name: "Code change - audio field only"
4345
description: "Apply patch to change Video audio code_version, observe field-level tracking"
4446
steps:
45-
- type: "apply_patch"
47+
- name: "update_audio_version"
48+
type: "apply_patch"
4649
patch_path: "patches/01_update_video_code_version.patch"
4750
description: "Change Video audio field code_version from 1 to 2"
4851

49-
- type: "run_command"
52+
- name: "recompute_after_audio_change"
53+
type: "run_command"
5054
command: "python pipeline.py"
5155
description: "Run pipeline after audio code_version change"
5256
capture_output: true
@@ -64,7 +68,8 @@ scenarios:
6468
- name: "Final idempotent check"
6569
description: "Verify no further recomputation needed after code change processed"
6670
steps:
67-
- type: "run_command"
71+
- name: "final_idempotent_check"
72+
type: "run_command"
6873
command: "python pipeline.py"
6974
description: "Rerun pipeline after code change"
7075
capture_output: true

examples/example-one-to-many/pipeline.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import random
23

34
import narwhals as nw
@@ -9,6 +10,9 @@
910

1011

1112
def main():
13+
# Set random seed from environment if provided (for deterministic testing)
14+
if seed_str := os.environ.get("RANDOM_SEED"):
15+
random.seed(int(seed_str))
1216
cfg = init_metaxy()
1317
store = cfg.get_store("dev")
1418

0 commit comments

Comments
 (0)