Skip to content

Commit b706a73

Browse files
committed
display graph diff in docs
1 parent 7931fa3 commit b706a73

File tree

4 files changed

+214
-87
lines changed

4 files changed

+214
-87
lines changed

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,18 @@ def get_patch_snapshots(
259259
or empty dict if no execution state exists.
260260
"""
261261
runbook = self.load_runbook(example_name)
262-
return runbook.get_patch_snapshots()
262+
patch_snapshots = runbook.get_patch_snapshots()
263+
264+
# TEMPORARY: Use real snapshots from the store for demonstration if no execution state
265+
# Remove this once runbook execution properly captures snapshots
266+
if not patch_snapshots and (
267+
example_name == "recompute" or example_name == "example-recompute"
268+
):
269+
return {
270+
"patches/01_update_parent_algorithm.patch": (
271+
"d49e39c7ad7523cd9d25e26f9f350b73c66c979abccf2f0caee84e489035ce82",
272+
"31195f21044a744b4ea70c1ffe6432182e989ede74ed92a8c99e8cd269f14477",
273+
)
274+
}
275+
276+
return patch_snapshots

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

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,6 @@ def on_config(self, config: Any) -> Any:
5656
Returns:
5757
Modified configuration object.
5858
"""
59-
print("DEBUG: MetaxyExamplesPlugin.on_config() called") # DEBUG
60-
6159
# Resolve examples_dir relative to docs_dir
6260
docs_dir = Path(config["docs_dir"])
6361
examples_dir = docs_dir / self.config["examples_dir"]
@@ -312,30 +310,50 @@ def _render_patch(self, example_name: str, params: dict[str, Any]) -> str:
312310
generated_file.write_text(content)
313311

314312
snippets_path = f".generated/{patch_path}"
315-
patch_render = self.renderer.render_snippet(
316-
path=snippets_path,
317-
show_line_numbers=True,
318-
hl_lines=None,
319-
)
320313

321314
# Check if we have graph diff snapshots and they are different
322315
if snapshots and snapshots[0] and snapshots[1] and snapshots[0] != snapshots[1]:
323316
# Create tabbed output with patch and graph diff
317+
example_dir = self.loader.get_example_dir(example_name)
324318
graph_diff = self.renderer.render_graph_diff(
325-
snapshots[0], snapshots[1], example_name
319+
snapshots[0], snapshots[1], example_name, example_dir
326320
)
327321

328322
if graph_diff:
323+
# For tabs, render the patch without collapsible wrapper
324+
patch_render = self.renderer.render_snippet(
325+
path=snippets_path,
326+
show_line_numbers=True,
327+
hl_lines=None,
328+
collapsible=False, # No collapsible wrapper in tabs
329+
)
329330
# Use pymdownx.tabbed for tabs
331+
# Indent the content for proper tab rendering
332+
patch_lines = patch_render.strip().split("\n")
333+
patch_indented = "\n".join(
334+
f" {line}" if line else "" for line in patch_lines
335+
)
336+
337+
graph_lines = graph_diff.strip().split("\n")
338+
graph_indented = "\n".join(
339+
f" {line}" if line else "" for line in graph_lines
340+
)
341+
330342
return f"""
331343
=== "Patch"
332344
333-
{patch_render}
345+
{patch_indented}
334346
335347
=== "Graph Diff"
336348
337-
{graph_diff}
349+
{graph_indented}
338350
"""
339351

340-
# No graph diff available, just return the patch
352+
# No graph diff available, return the patch with collapsible wrapper
353+
patch_render = self.renderer.render_snippet(
354+
path=snippets_path,
355+
show_line_numbers=True,
356+
hl_lines=None,
357+
collapsible=True, # Use collapsible when not in tabs
358+
)
341359
return patch_render

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

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
from pathlib import Path
1112
from typing import Any
1213

1314

@@ -236,35 +237,50 @@ def render_error(self, message: str, details: str | None = None) -> str:
236237
return "\n".join(md_parts)
237238

238239
def render_graph_diff(
239-
self, from_snapshot: str, to_snapshot: str, example_name: str
240+
self, from_snapshot: str, to_snapshot: str, example_name: str, example_dir: Path
240241
) -> str | None:
241-
"""Render a graph diff as Mermaid diagram.
242+
"""Render a graph diff as Mermaid diagram using the CLI.
242243
243244
Args:
244245
from_snapshot: Before snapshot version hash.
245246
to_snapshot: After snapshot version hash.
246247
example_name: Name of the example for context.
248+
example_dir: Path to the example directory.
247249
248250
Returns:
249251
Mermaid diagram string or None if rendering fails.
250252
"""
253+
import subprocess
254+
import sys
255+
251256
try:
252-
# We need access to the example's config to get the store
253-
# For now, we'll generate a placeholder
254-
# In a real implementation, we'd need to:
255-
# 1. Load the example's config
256-
# 2. Get the metadata store
257-
# 3. Use GraphDiffer to generate the diff
258-
# 4. Use MermaidRenderer to render it
259-
260-
# Placeholder that shows the concept
261-
return f"""```mermaid
262-
%%{{init: {{'flowchart': {{'htmlLabels': true, 'curve': 'basis'}}, 'themeVariables': {{'fontSize': '14px'}}}}}}%%
263-
flowchart TD
264-
%% Graph diff from {from_snapshot[:8]} to {to_snapshot[:8]}
265-
placeholder[Graph diff visualization would appear here]
266-
note[This requires metadata store access to render]
267-
```"""
268-
269-
except Exception:
270-
return None
257+
# Run metaxy graph-diff render command from the example directory
258+
# Use the metaxy CLI entry point directly
259+
import os
260+
261+
metaxy_path = os.path.join(os.path.dirname(sys.executable), "metaxy")
262+
263+
result = subprocess.run(
264+
[
265+
metaxy_path,
266+
"graph-diff",
267+
"render",
268+
"--format",
269+
"mermaid",
270+
from_snapshot,
271+
to_snapshot,
272+
],
273+
capture_output=True,
274+
text=True,
275+
cwd=example_dir,
276+
)
277+
278+
if result.returncode != 0:
279+
return f"```\nError rendering graph diff: {result.stderr}\n```"
280+
281+
# Wrap the mermaid output in a code block
282+
return f"```mermaid\n{result.stdout}\n```"
283+
284+
except Exception as e:
285+
# Return a simple error message in case of failure
286+
return f"```\nError rendering graph diff: {e}\n```"

src/metaxy/graph/diff/differ.py

Lines changed: 133 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,117 @@ def _get_downstream_features(
625625

626626
return downstream
627627

628+
def _compute_field_version_from_spec(
629+
self,
630+
feature_key: str,
631+
field_key: str,
632+
field_code_version: str,
633+
field_deps: list[dict[str, Any]],
634+
all_specs: dict[str, dict[str, Any]],
635+
computed_versions: dict[tuple[str, str], str],
636+
) -> str:
637+
"""Compute field version from snapshot spec without loading Feature classes.
638+
639+
Args:
640+
feature_key: Feature key string
641+
field_key: Field key string
642+
field_code_version: Code version of the field
643+
field_deps: Field dependencies from spec
644+
all_specs: All feature specs in the snapshot
645+
computed_versions: Cache of already computed field versions
646+
647+
Returns:
648+
Computed field version hash
649+
"""
650+
import hashlib
651+
652+
# Check cache
653+
cache_key = (feature_key, field_key)
654+
if cache_key in computed_versions:
655+
return computed_versions[cache_key]
656+
657+
# Create hasher
658+
hasher = hashlib.sha256()
659+
660+
# Add fully qualified field key
661+
fq_key = f"{feature_key}/{field_key}"
662+
hasher.update(fq_key.encode())
663+
664+
# Add field's own code version
665+
hasher.update(str(field_code_version).encode())
666+
667+
# Add dependent field versions
668+
for dep in field_deps:
669+
dep_feature = dep.get("feature") or dep.get("key", [])
670+
if isinstance(dep_feature, list):
671+
dep_feature_str = "/".join(dep_feature)
672+
else:
673+
dep_feature_str = dep_feature
674+
675+
dep_fields = dep.get("fields", [])
676+
if not dep_fields:
677+
# If no specific fields, depend on all fields of the upstream feature
678+
if dep_feature_str in all_specs:
679+
dep_spec = all_specs[dep_feature_str]["feature_spec"]
680+
for dep_field_dict in dep_spec.get("fields", []):
681+
dep_field_key = dep_field_dict.get("key", [])
682+
if isinstance(dep_field_key, list):
683+
dep_field_str = "/".join(dep_field_key)
684+
else:
685+
dep_field_str = dep_field_key
686+
687+
# Recursively compute dependent field version
688+
dep_field_version = self._compute_field_version_from_spec(
689+
dep_feature_str,
690+
dep_field_str,
691+
dep_field_dict.get("code_version", "__metaxy_initial__"),
692+
dep_field_dict.get("deps", []),
693+
all_specs,
694+
computed_versions,
695+
)
696+
hasher.update(dep_field_version.encode())
697+
else:
698+
# Specific field dependencies
699+
for dep_field in dep_fields:
700+
if isinstance(dep_field, list):
701+
dep_field_str = "/".join(dep_field)
702+
else:
703+
dep_field_str = dep_field
704+
705+
# Find the field spec in the upstream feature
706+
if dep_feature_str in all_specs:
707+
dep_spec = all_specs[dep_feature_str]["feature_spec"]
708+
for dep_field_dict in dep_spec.get("fields", []):
709+
field_key_from_spec = dep_field_dict.get("key", [])
710+
if isinstance(field_key_from_spec, list):
711+
field_str_from_spec = "/".join(field_key_from_spec)
712+
else:
713+
field_str_from_spec = field_key_from_spec
714+
715+
if field_str_from_spec == dep_field_str:
716+
# Found the field, compute its version
717+
dep_field_version = (
718+
self._compute_field_version_from_spec(
719+
dep_feature_str,
720+
dep_field_str,
721+
dep_field_dict.get(
722+
"code_version", "__metaxy_initial__"
723+
),
724+
dep_field_dict.get("deps", []),
725+
all_specs,
726+
computed_versions,
727+
)
728+
)
729+
hasher.update(dep_field_version.encode())
730+
break
731+
732+
# Compute and cache the version
733+
from metaxy.utils.hashing import truncate_hash
734+
735+
version = truncate_hash(hasher.hexdigest())
736+
computed_versions[cache_key] = version
737+
return version
738+
628739
def load_snapshot_data(
629740
self, store: MetadataStore, snapshot_version: str, project: str | None = None
630741
) -> Mapping[str, Mapping[str, Any]]:
@@ -696,66 +807,34 @@ def load_snapshot_data(
696807
), # Fallback for backward compatibility
697808
}
698809

699-
# Try to reconstruct FeatureGraph from snapshot to compute field versions
700-
# This may fail if features have been removed/moved, so we handle that gracefully
701-
graph: FeatureGraph | None = None
702-
try:
703-
graph = FeatureGraph.from_snapshot(snapshot_dict)
704-
graph_available = True
705-
except ImportError:
706-
# Some features can't be imported (likely removed) - proceed without graph
707-
# For diff purposes, we can still show feature-level changes
708-
# We'll use feature_version as a fallback for all field versions
709-
graph_available = False
710-
warnings.warn(
711-
"Using feature_version as field_version fallback for features that cannot be imported. "
712-
"This may occur when features have been removed or moved.",
713-
UserWarning,
714-
stacklevel=2,
715-
)
716-
717-
# Compute field versions using the reconstructed graph (if available)
718-
from metaxy.models.plan import FQFieldKey
719-
810+
# Compute field versions directly from snapshot specs without loading Feature classes
811+
# This ensures we use the actual snapshot's field specs, not the current code
720812
snapshot_data = {}
813+
computed_versions: dict[tuple[str, str], str] = {}
814+
721815
for feature_key_str in snapshot_dict.keys():
722816
feature_version = snapshot_dict[feature_key_str]["metaxy_feature_version"]
723817
feature_spec = snapshot_dict[feature_key_str]["feature_spec"]
724-
feature_key_obj = FeatureKey(feature_key_str.split("/"))
725818

726-
# Compute field versions using graph (if available)
819+
# Compute field versions from the snapshot's specs
727820
fields_data = {}
728-
if (
729-
graph_available
730-
and graph is not None
731-
and feature_key_obj in graph.features_by_key
732-
):
733-
# Feature exists in reconstructed graph - compute precise field versions
734-
for field_dict in feature_spec.get("fields", []):
735-
field_key_list = field_dict.get("key")
736-
if isinstance(field_key_list, list):
737-
field_key = FieldKey(field_key_list)
738-
field_key_str_normalized = "/".join(field_key_list)
739-
else:
740-
field_key = FieldKey([field_key_list])
741-
field_key_str_normalized = field_key_list
742-
743-
# Compute field version using the graph
744-
fq_key = FQFieldKey(feature=feature_key_obj, field=field_key)
745-
field_version = graph.get_field_version(fq_key)
746-
fields_data[field_key_str_normalized] = field_version
747-
else:
748-
# Feature doesn't exist in graph (removed/moved) - use feature_version as fallback
749-
# All fields get the same version (the feature version)
750-
for field_dict in feature_spec.get("fields", []):
751-
field_key_list = field_dict.get("key")
752-
if isinstance(field_key_list, list):
753-
field_key_str_normalized = "/".join(field_key_list)
754-
else:
755-
field_key_str_normalized = field_key_list
756-
757-
# Use feature_version directly as fallback
758-
fields_data[field_key_str_normalized] = feature_version
821+
for field_dict in feature_spec.get("fields", []):
822+
field_key_list = field_dict.get("key")
823+
if isinstance(field_key_list, list):
824+
field_key_str_normalized = "/".join(field_key_list)
825+
else:
826+
field_key_str_normalized = field_key_list
827+
828+
# Compute field version using the snapshot's specs
829+
field_version = self._compute_field_version_from_spec(
830+
feature_key_str,
831+
field_key_str_normalized,
832+
field_dict.get("code_version", "__metaxy_initial__"),
833+
field_dict.get("deps", []),
834+
snapshot_dict,
835+
computed_versions,
836+
)
837+
fields_data[field_key_str_normalized] = field_version
759838

760839
snapshot_data[feature_key_str] = {
761840
"metaxy_feature_version": feature_version,

0 commit comments

Comments
 (0)