metaxy 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of metaxy might be problematic. Click here for more details.

Files changed (75) hide show
  1. metaxy/__init__.py +61 -0
  2. metaxy/_testing.py +542 -0
  3. metaxy/_utils.py +16 -0
  4. metaxy/_version.py +1 -0
  5. metaxy/cli/app.py +76 -0
  6. metaxy/cli/context.py +71 -0
  7. metaxy/cli/graph.py +576 -0
  8. metaxy/cli/graph_diff.py +290 -0
  9. metaxy/cli/list.py +42 -0
  10. metaxy/cli/metadata.py +271 -0
  11. metaxy/cli/migrations.py +862 -0
  12. metaxy/cli/push.py +55 -0
  13. metaxy/config.py +450 -0
  14. metaxy/data_versioning/__init__.py +24 -0
  15. metaxy/data_versioning/calculators/__init__.py +13 -0
  16. metaxy/data_versioning/calculators/base.py +97 -0
  17. metaxy/data_versioning/calculators/duckdb.py +186 -0
  18. metaxy/data_versioning/calculators/ibis.py +225 -0
  19. metaxy/data_versioning/calculators/polars.py +135 -0
  20. metaxy/data_versioning/diff/__init__.py +15 -0
  21. metaxy/data_versioning/diff/base.py +150 -0
  22. metaxy/data_versioning/diff/narwhals.py +108 -0
  23. metaxy/data_versioning/hash_algorithms.py +19 -0
  24. metaxy/data_versioning/joiners/__init__.py +9 -0
  25. metaxy/data_versioning/joiners/base.py +70 -0
  26. metaxy/data_versioning/joiners/narwhals.py +235 -0
  27. metaxy/entrypoints.py +309 -0
  28. metaxy/ext/__init__.py +1 -0
  29. metaxy/ext/alembic.py +326 -0
  30. metaxy/ext/sqlmodel.py +172 -0
  31. metaxy/ext/sqlmodel_system_tables.py +139 -0
  32. metaxy/graph/__init__.py +21 -0
  33. metaxy/graph/diff/__init__.py +21 -0
  34. metaxy/graph/diff/diff_models.py +399 -0
  35. metaxy/graph/diff/differ.py +740 -0
  36. metaxy/graph/diff/models.py +418 -0
  37. metaxy/graph/diff/rendering/__init__.py +18 -0
  38. metaxy/graph/diff/rendering/base.py +274 -0
  39. metaxy/graph/diff/rendering/cards.py +188 -0
  40. metaxy/graph/diff/rendering/formatter.py +805 -0
  41. metaxy/graph/diff/rendering/graphviz.py +246 -0
  42. metaxy/graph/diff/rendering/mermaid.py +320 -0
  43. metaxy/graph/diff/rendering/rich.py +165 -0
  44. metaxy/graph/diff/rendering/theme.py +48 -0
  45. metaxy/graph/diff/traversal.py +247 -0
  46. metaxy/graph/utils.py +58 -0
  47. metaxy/metadata_store/__init__.py +31 -0
  48. metaxy/metadata_store/_protocols.py +38 -0
  49. metaxy/metadata_store/base.py +1676 -0
  50. metaxy/metadata_store/clickhouse.py +161 -0
  51. metaxy/metadata_store/duckdb.py +167 -0
  52. metaxy/metadata_store/exceptions.py +43 -0
  53. metaxy/metadata_store/ibis.py +451 -0
  54. metaxy/metadata_store/memory.py +228 -0
  55. metaxy/metadata_store/sqlite.py +187 -0
  56. metaxy/metadata_store/system_tables.py +257 -0
  57. metaxy/migrations/__init__.py +34 -0
  58. metaxy/migrations/detector.py +153 -0
  59. metaxy/migrations/executor.py +208 -0
  60. metaxy/migrations/loader.py +260 -0
  61. metaxy/migrations/models.py +718 -0
  62. metaxy/migrations/ops.py +390 -0
  63. metaxy/models/__init__.py +0 -0
  64. metaxy/models/bases.py +6 -0
  65. metaxy/models/constants.py +24 -0
  66. metaxy/models/feature.py +665 -0
  67. metaxy/models/feature_spec.py +105 -0
  68. metaxy/models/field.py +25 -0
  69. metaxy/models/plan.py +155 -0
  70. metaxy/models/types.py +157 -0
  71. metaxy/py.typed +0 -0
  72. metaxy-0.0.0.dist-info/METADATA +247 -0
  73. metaxy-0.0.0.dist-info/RECORD +75 -0
  74. metaxy-0.0.0.dist-info/WHEEL +4 -0
  75. metaxy-0.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,418 @@
1
+ """Core data models for graph rendering."""
2
+
3
+ from enum import Enum
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from pydantic import Field
7
+ from typing_extensions import Self
8
+
9
+ from metaxy.models.bases import FrozenBaseModel
10
+ from metaxy.models.types import FeatureKey, FieldKey
11
+
12
+ if TYPE_CHECKING:
13
+ from metaxy.models.feature import FeatureGraph
14
+
15
+
16
+ class NodeStatus(str, Enum):
17
+ """Status of a node in a diff view."""
18
+
19
+ NORMAL = "normal" # Normal node (not in diff mode)
20
+ UNCHANGED = "unchanged" # Unchanged in diff
21
+ ADDED = "added" # Added in diff
22
+ REMOVED = "removed" # Removed in diff
23
+ CHANGED = "changed" # Changed in diff
24
+
25
+
26
+ class FieldNode(FrozenBaseModel):
27
+ """Represents a field within a feature node.
28
+
29
+ Attributes:
30
+ key: Field key
31
+ version: Current field version hash
32
+ old_version: Previous field version hash (for diffs)
33
+ code_version: Code version (if available)
34
+ status: Field status (for diff rendering)
35
+ """
36
+
37
+ key: FieldKey
38
+ version: str | None = None # None if field was removed
39
+ old_version: str | None = None # For diff mode
40
+ code_version: int | None = None
41
+ status: NodeStatus = NodeStatus.NORMAL
42
+
43
+
44
+ class GraphNode(FrozenBaseModel):
45
+ """Represents a feature node in the graph.
46
+
47
+ Attributes:
48
+ key: Feature key
49
+ version: Current feature version hash
50
+ old_version: Previous feature version hash (for diffs)
51
+ code_version: Code version (if available)
52
+ fields: List of field nodes
53
+ dependencies: List of feature keys this node depends on
54
+ status: Node status (for diff rendering)
55
+ metadata: Additional custom metadata
56
+ """
57
+
58
+ key: FeatureKey
59
+ version: str | None = None # None if feature was removed
60
+ old_version: str | None = None # For diff mode
61
+ code_version: int | None = None
62
+ fields: list[FieldNode] = Field(default_factory=list)
63
+ dependencies: list[FeatureKey] = Field(default_factory=list)
64
+ status: NodeStatus = NodeStatus.NORMAL
65
+ metadata: dict[str, Any] = Field(default_factory=dict)
66
+
67
+
68
+ class EdgeData(FrozenBaseModel):
69
+ """Represents an edge between two nodes.
70
+
71
+ Attributes:
72
+ from_key: Source feature key (dependency)
73
+ to_key: Target feature key (dependent)
74
+ """
75
+
76
+ from_key: FeatureKey
77
+ to_key: FeatureKey
78
+
79
+
80
+ class GraphData(FrozenBaseModel):
81
+ """Container for complete graph structure.
82
+
83
+ This is the unified data model used by all renderers.
84
+
85
+ Attributes:
86
+ nodes: Map from feature key string to GraphNode
87
+ edges: List of edges
88
+ snapshot_version: Optional snapshot version
89
+ old_snapshot_version: Optional old snapshot version (for diffs)
90
+ """
91
+
92
+ nodes: dict[str, GraphNode] # Key is feature_key.to_string()
93
+ edges: list[EdgeData] = Field(default_factory=list)
94
+ snapshot_version: str | None = None
95
+ old_snapshot_version: str | None = None # For diff mode
96
+
97
+ def get_node(self, key: FeatureKey) -> GraphNode | None:
98
+ """Get node by feature key.
99
+
100
+ Args:
101
+ key: Feature key to lookup
102
+
103
+ Returns:
104
+ GraphNode if found, None otherwise
105
+ """
106
+ return self.nodes.get(key.to_string())
107
+
108
+ def get_nodes_by_status(self, status: NodeStatus) -> list[GraphNode]:
109
+ """Get all nodes with a specific status.
110
+
111
+ Args:
112
+ status: Status to filter by
113
+
114
+ Returns:
115
+ List of nodes with matching status
116
+ """
117
+ return [node for node in self.nodes.values() if node.status == status]
118
+
119
+ def to_struct(self) -> dict[str, Any]:
120
+ """Serialize to struct (native Python types for storage).
121
+
122
+ Note: This uses custom serialization instead of Pydantic's model_dump() because:
123
+ 1. Polars struct columns require specific type conversions (e.g., None → "" for strings, None → 0 for ints)
124
+ 2. Custom types (FeatureKey, FieldKey) need explicit string conversion for storage
125
+ 3. The storage schema is a separate concern from the domain model's Python representation
126
+ 4. Different storage backends may need different serialization formats in the future
127
+
128
+ Returns:
129
+ Dict with structure compatible with Polars struct type
130
+ """
131
+ nodes_list = []
132
+ for node in self.nodes.values():
133
+ fields_list = []
134
+ for field in node.fields:
135
+ fields_list.append(
136
+ {
137
+ "key": field.key.to_string(),
138
+ "version": field.version if field.version is not None else "",
139
+ "code_version": field.code_version
140
+ if field.code_version is not None
141
+ else 0,
142
+ }
143
+ )
144
+
145
+ nodes_list.append(
146
+ {
147
+ "key": node.key.to_string(),
148
+ "version": node.version if node.version is not None else "",
149
+ "code_version": node.code_version
150
+ if node.code_version is not None
151
+ else 0,
152
+ "fields": fields_list,
153
+ "dependencies": [dep.to_string() for dep in node.dependencies],
154
+ }
155
+ )
156
+
157
+ edges_list = []
158
+ for edge in self.edges:
159
+ edges_list.append(
160
+ {
161
+ "from_key": edge.from_key.to_string(),
162
+ "to_key": edge.to_key.to_string(),
163
+ }
164
+ )
165
+
166
+ result: dict[str, Any] = {
167
+ "nodes": nodes_list,
168
+ "edges": edges_list,
169
+ }
170
+
171
+ # Include snapshot_version if present
172
+ if self.snapshot_version is not None:
173
+ result["snapshot_version"] = self.snapshot_version
174
+
175
+ # Include old_snapshot_version if present (for diffs)
176
+ if self.old_snapshot_version is not None:
177
+ result["old_snapshot_version"] = self.old_snapshot_version
178
+
179
+ return result
180
+
181
+ @classmethod
182
+ def from_struct(cls, struct_data: dict[str, Any]) -> Self:
183
+ """Deserialize from struct.
184
+
185
+ Args:
186
+ struct_data: Dict with structure from to_struct()
187
+
188
+ Returns:
189
+ GraphData instance
190
+ """
191
+ nodes = {}
192
+ for node_data in struct_data["nodes"]:
193
+ fields = []
194
+ for field_data in node_data["fields"]:
195
+ fields.append(
196
+ FieldNode(
197
+ key=FieldKey(field_data["key"].split("/")),
198
+ version=field_data["version"]
199
+ if field_data["version"]
200
+ else None,
201
+ code_version=field_data["code_version"]
202
+ if field_data["code_version"] != 0
203
+ else None,
204
+ )
205
+ )
206
+
207
+ node = GraphNode(
208
+ key=FeatureKey(node_data["key"].split("/")),
209
+ version=node_data["version"] if node_data["version"] else None,
210
+ code_version=node_data["code_version"]
211
+ if node_data["code_version"] != 0
212
+ else None,
213
+ fields=fields,
214
+ dependencies=[
215
+ FeatureKey(dep.split("/")) for dep in node_data["dependencies"]
216
+ ],
217
+ )
218
+ nodes[node_data["key"]] = node
219
+
220
+ edges = []
221
+ for edge_data in struct_data["edges"]:
222
+ edges.append(
223
+ EdgeData(
224
+ from_key=FeatureKey(edge_data["from_key"].split("/")),
225
+ to_key=FeatureKey(edge_data["to_key"].split("/")),
226
+ )
227
+ )
228
+
229
+ # Extract snapshot_version if present
230
+ snapshot_version = struct_data.get("snapshot_version")
231
+
232
+ # Extract old_snapshot_version if present (for diffs)
233
+ old_snapshot_version = struct_data.get("old_snapshot_version")
234
+
235
+ return cls(
236
+ nodes=nodes,
237
+ edges=edges,
238
+ snapshot_version=snapshot_version,
239
+ old_snapshot_version=old_snapshot_version,
240
+ )
241
+
242
+ @classmethod
243
+ def from_feature_graph(cls, graph: "FeatureGraph") -> "GraphData":
244
+ """Convert a FeatureGraph to GraphData.
245
+
246
+ Args:
247
+ graph: FeatureGraph instance
248
+
249
+ Returns:
250
+ GraphData with all nodes and edges
251
+ """
252
+ from metaxy.models.plan import FQFieldKey
253
+
254
+ nodes: dict[str, GraphNode] = {}
255
+ edges: list[EdgeData] = []
256
+
257
+ # Convert each feature to a GraphNode
258
+ for feature_key, feature_cls in graph.features_by_key.items():
259
+ feature_key_str = feature_key.to_string()
260
+ spec = feature_cls.spec
261
+
262
+ # Get feature version
263
+ feature_version = graph.get_feature_version(feature_key)
264
+
265
+ # Convert fields
266
+ field_nodes: list[FieldNode] = []
267
+ if spec.fields:
268
+ for field_spec in spec.fields:
269
+ # Compute field version
270
+ fq_field_key = FQFieldKey(feature=feature_key, field=field_spec.key)
271
+ field_version = graph.get_field_version(fq_field_key)
272
+
273
+ field_node = FieldNode(
274
+ key=field_spec.key,
275
+ version=field_version,
276
+ code_version=field_spec.code_version,
277
+ status=NodeStatus.NORMAL,
278
+ )
279
+ field_nodes.append(field_node)
280
+
281
+ # Extract dependencies
282
+ dependencies: list[FeatureKey] = []
283
+ if spec.deps:
284
+ dependencies = [dep.key for dep in spec.deps]
285
+
286
+ # Create node
287
+ node = GraphNode(
288
+ key=feature_key,
289
+ version=feature_version,
290
+ code_version=spec.code_version,
291
+ fields=field_nodes,
292
+ dependencies=dependencies,
293
+ status=NodeStatus.NORMAL,
294
+ )
295
+ nodes[feature_key_str] = node
296
+
297
+ # Create edges
298
+ for dep_key in dependencies:
299
+ edges.append(EdgeData(from_key=dep_key, to_key=feature_key))
300
+
301
+ return cls(
302
+ nodes=nodes,
303
+ edges=edges,
304
+ snapshot_version=graph.snapshot_version,
305
+ )
306
+
307
+ @classmethod
308
+ def from_merged_diff(cls, merged_data: dict[str, Any]) -> "GraphData":
309
+ """Convert merged diff data to GraphData.
310
+
311
+ Args:
312
+ merged_data: Merged diff data from GraphDiffer.create_merged_graph_data()
313
+
314
+ Returns:
315
+ GraphData with status annotations
316
+ """
317
+ from metaxy.graph.diff.diff_models import FieldChange
318
+
319
+ nodes: dict[str, GraphNode] = {}
320
+ edges: list[EdgeData] = []
321
+
322
+ # Convert nodes
323
+ for feature_key_str, node_data in merged_data["nodes"].items():
324
+ # Parse feature key
325
+ feature_key = FeatureKey(feature_key_str.split("/"))
326
+
327
+ # Map status strings to NodeStatus enum
328
+ status_str = node_data["status"]
329
+ if status_str == "added":
330
+ status = NodeStatus.ADDED
331
+ elif status_str == "removed":
332
+ status = NodeStatus.REMOVED
333
+ elif status_str == "changed":
334
+ status = NodeStatus.CHANGED
335
+ elif status_str == "unchanged":
336
+ status = NodeStatus.UNCHANGED
337
+ else:
338
+ status = NodeStatus.NORMAL
339
+
340
+ # Convert fields
341
+ fields_dict = node_data.get("fields", {})
342
+ field_changes_list = node_data.get("field_changes", [])
343
+
344
+ # Build field change map for quick lookup
345
+ field_change_map: dict[str, FieldChange] = {}
346
+ for fc in field_changes_list:
347
+ if isinstance(fc, FieldChange):
348
+ field_change_map[fc.field_key.to_string()] = fc
349
+
350
+ # Get all field keys (from both current fields and removed fields in changes)
351
+ all_field_keys = set(fields_dict.keys())
352
+ all_field_keys.update(field_change_map.keys())
353
+
354
+ field_nodes: list[FieldNode] = []
355
+ for field_key_str in all_field_keys:
356
+ # Parse field key
357
+ field_key = FieldKey(field_key_str.split("/"))
358
+
359
+ # Determine field status and versions
360
+ if field_key_str in field_change_map:
361
+ fc = field_change_map[field_key_str]
362
+ if fc.is_added:
363
+ field_status = NodeStatus.ADDED
364
+ field_version = fc.new_version
365
+ old_field_version = None
366
+ elif fc.is_removed:
367
+ field_status = NodeStatus.REMOVED
368
+ field_version = None
369
+ old_field_version = fc.old_version
370
+ elif fc.is_changed:
371
+ field_status = NodeStatus.CHANGED
372
+ field_version = fc.new_version
373
+ old_field_version = fc.old_version
374
+ else:
375
+ field_status = NodeStatus.UNCHANGED
376
+ field_version = fc.new_version or fc.old_version
377
+ old_field_version = None
378
+ else:
379
+ # Unchanged field
380
+ field_status = NodeStatus.UNCHANGED
381
+ field_version = fields_dict.get(field_key_str)
382
+ old_field_version = None
383
+
384
+ field_node = FieldNode(
385
+ key=field_key,
386
+ version=field_version,
387
+ old_version=old_field_version,
388
+ status=field_status,
389
+ )
390
+ field_nodes.append(field_node)
391
+
392
+ # Parse dependencies
393
+ dependencies = [
394
+ FeatureKey(dep_str.split("/"))
395
+ for dep_str in node_data.get("dependencies", [])
396
+ ]
397
+
398
+ # Create node
399
+ node = GraphNode(
400
+ key=feature_key,
401
+ version=node_data.get("new_version"),
402
+ old_version=node_data.get("old_version"),
403
+ fields=field_nodes,
404
+ dependencies=dependencies,
405
+ status=status,
406
+ )
407
+ nodes[feature_key_str] = node
408
+
409
+ # Convert edges
410
+ for edge_dict in merged_data["edges"]:
411
+ from_key = FeatureKey(edge_dict["from"].split("/"))
412
+ to_key = FeatureKey(edge_dict["to"].split("/"))
413
+ edges.append(EdgeData(from_key=from_key, to_key=to_key))
414
+
415
+ return cls(
416
+ nodes=nodes,
417
+ edges=edges,
418
+ )
@@ -0,0 +1,18 @@
1
+ """Graph rendering - visualization backends for graphs and diffs."""
2
+
3
+ from metaxy.graph.diff.rendering.base import BaseRenderer, RenderConfig
4
+ from metaxy.graph.diff.rendering.cards import CardsRenderer
5
+ from metaxy.graph.diff.rendering.graphviz import GraphvizRenderer
6
+ from metaxy.graph.diff.rendering.mermaid import MermaidRenderer
7
+ from metaxy.graph.diff.rendering.rich import TerminalRenderer
8
+ from metaxy.graph.diff.rendering.theme import Theme
9
+
10
+ __all__ = [
11
+ "BaseRenderer",
12
+ "RenderConfig",
13
+ "TerminalRenderer",
14
+ "CardsRenderer",
15
+ "MermaidRenderer",
16
+ "GraphvizRenderer",
17
+ "Theme",
18
+ ]
@@ -0,0 +1,274 @@
1
+ """Base classes and configuration for graph rendering."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from metaxy.graph.diff.models import GraphData, NodeStatus
6
+ from metaxy.graph.diff.rendering.theme import Theme
7
+ from metaxy.graph.diff.traversal import GraphWalker
8
+ from metaxy.graph.utils import format_feature_key, format_field_key, format_hash
9
+ from metaxy.models.feature import FeatureGraph
10
+ from metaxy.models.types import FeatureKey, FieldKey
11
+
12
+
13
+ @dataclass
14
+ class RenderConfig:
15
+ """Configuration for graph rendering.
16
+
17
+ Controls what information is displayed and how it's formatted.
18
+ """
19
+
20
+ # What to show
21
+ show_fields: bool = field(
22
+ default=True,
23
+ metadata={"help": "Show field-level details within features"},
24
+ )
25
+
26
+ show_feature_versions: bool = field(
27
+ default=True,
28
+ metadata={"help": "Show feature version hashes"},
29
+ )
30
+
31
+ show_field_versions: bool = field(
32
+ default=True,
33
+ metadata={"help": "Show field version hashes (requires --show-fields)"},
34
+ )
35
+
36
+ show_code_versions: bool = field(
37
+ default=False,
38
+ metadata={"help": "Show feature and field code versions"},
39
+ )
40
+
41
+ show_snapshot_version: bool = field(
42
+ default=True,
43
+ metadata={"help": "Show graph snapshot version in output"},
44
+ )
45
+
46
+ # Display options
47
+ hash_length: int = field(
48
+ default=8,
49
+ metadata={
50
+ "help": "Number of characters to show for version hashes (0 for full)"
51
+ },
52
+ )
53
+
54
+ direction: str = field(
55
+ default="TB",
56
+ metadata={"help": "Graph layout direction: TB (top-bottom) or LR (left-right)"},
57
+ )
58
+
59
+ # Filtering options
60
+ feature: str | None = field(
61
+ default=None,
62
+ metadata={
63
+ "help": "Focus on a specific feature (e.g., 'video/files' or 'video__files')"
64
+ },
65
+ )
66
+
67
+ up: int | None = field(
68
+ default=None,
69
+ metadata={
70
+ "help": "Number of dependency levels to render upstream (default: all)"
71
+ },
72
+ )
73
+
74
+ down: int | None = field(
75
+ default=None,
76
+ metadata={
77
+ "help": "Number of dependency levels to render downstream (default: all)"
78
+ },
79
+ )
80
+
81
+ def get_feature_key(self) -> FeatureKey | None:
82
+ """Parse feature string into FeatureKey.
83
+
84
+ Returns:
85
+ FeatureKey if feature is set, None otherwise
86
+ """
87
+ if self.feature is None:
88
+ return None
89
+
90
+ # Support both formats: "video__files" or "video/files"
91
+ if "/" in self.feature:
92
+ return FeatureKey(self.feature.split("/"))
93
+ else:
94
+ return FeatureKey(self.feature.split("__"))
95
+
96
+ @classmethod
97
+ def minimal(cls) -> "RenderConfig":
98
+ """Preset: minimal information (structure only)."""
99
+ return cls(
100
+ show_fields=False,
101
+ show_feature_versions=False,
102
+ show_field_versions=False,
103
+ show_code_versions=False,
104
+ show_snapshot_version=False,
105
+ )
106
+
107
+ @classmethod
108
+ def default(cls) -> "RenderConfig":
109
+ """Preset: default information level (balanced)."""
110
+ return cls(
111
+ show_fields=True,
112
+ show_feature_versions=True,
113
+ show_field_versions=True,
114
+ show_code_versions=False,
115
+ show_snapshot_version=True,
116
+ hash_length=8,
117
+ )
118
+
119
+ @classmethod
120
+ def verbose(cls) -> "RenderConfig":
121
+ """Preset: maximum information (everything)."""
122
+ return cls(
123
+ show_fields=True,
124
+ show_feature_versions=True,
125
+ show_field_versions=True,
126
+ show_code_versions=True,
127
+ show_snapshot_version=True,
128
+ hash_length=0, # Full hashes
129
+ )
130
+
131
+
132
+ class BaseRenderer:
133
+ """Base class for graph renderers.
134
+
135
+ Provides common utilities for formatting keys and hashes.
136
+ Uses unified GraphData model and Theme system.
137
+ """
138
+
139
+ def __init__(
140
+ self,
141
+ graph: FeatureGraph | None = None,
142
+ config: RenderConfig | None = None,
143
+ graph_data: GraphData | None = None,
144
+ theme: Theme | None = None,
145
+ ):
146
+ """Initialize renderer.
147
+
148
+ Args:
149
+ graph: FeatureGraph (converted to GraphData automatically)
150
+ config: Render configuration
151
+ graph_data: GraphData (alternative to feature graph)
152
+ theme: Color theme (uses default if None)
153
+
154
+ Note:
155
+ Either graph or graph_data must be provided.
156
+ If both are provided, an error is raised.
157
+ """
158
+ if graph_data is None and graph is None:
159
+ raise ValueError("Either graph or graph_data must be provided")
160
+
161
+ # Prefer graph_data if provided, otherwise convert from graph
162
+ if graph_data is not None:
163
+ self.graph_data: GraphData = graph_data
164
+ else:
165
+ # graph is not None (validated above)
166
+ assert graph is not None
167
+ self.graph_data = GraphData.from_feature_graph(graph)
168
+
169
+ self.config = config or RenderConfig()
170
+ self.theme = theme or Theme.default()
171
+ self.walker = GraphWalker(self.graph_data)
172
+
173
+ def _format_hash(self, hash_str: str | None) -> str:
174
+ """Format hash according to config.
175
+
176
+ Args:
177
+ hash_str: Full hash string (or None for removed nodes)
178
+
179
+ Returns:
180
+ Truncated hash if hash_length > 0, otherwise full hash
181
+ """
182
+ if hash_str is None:
183
+ return "none"
184
+ return format_hash(hash_str, length=self.config.hash_length)
185
+
186
+ def _get_status_color(self, status: NodeStatus) -> str:
187
+ """Get color for a given status.
188
+
189
+ Args:
190
+ status: Node or field status
191
+
192
+ Returns:
193
+ Color string for Rich markup
194
+ """
195
+ if status == NodeStatus.ADDED:
196
+ return self.theme.added_color
197
+ elif status == NodeStatus.REMOVED:
198
+ return self.theme.removed_color
199
+ elif status == NodeStatus.CHANGED:
200
+ return self.theme.changed_color
201
+ elif status == NodeStatus.UNCHANGED:
202
+ return self.theme.unchanged_color
203
+ else:
204
+ return self.theme.feature_color
205
+
206
+ def _format_version_transition(
207
+ self, old_version: str | None, new_version: str | None
208
+ ) -> str:
209
+ """Format version transition for diff display.
210
+
211
+ Args:
212
+ old_version: Old version hash
213
+ new_version: New version hash
214
+
215
+ Returns:
216
+ Formatted string like "old... → new..."
217
+ """
218
+ old_str = self._format_hash(old_version)
219
+ new_str = self._format_hash(new_version)
220
+ return (
221
+ f"[{self.theme.old_version_color}]{old_str}[/{self.theme.old_version_color}]... → "
222
+ f"[{self.theme.new_version_color}]{new_str}[/{self.theme.new_version_color}]..."
223
+ )
224
+
225
+ def _format_feature_key(self, key: FeatureKey) -> str:
226
+ """Format feature key for display.
227
+
228
+ Uses / separator instead of __ for better readability.
229
+
230
+ Args:
231
+ key: Feature key
232
+
233
+ Returns:
234
+ Formatted string like "my/feature/key"
235
+ """
236
+ return format_feature_key(key)
237
+
238
+ def _format_field_key(self, key: FieldKey) -> str:
239
+ """Format field key for display.
240
+
241
+ Args:
242
+ key: Field key
243
+
244
+ Returns:
245
+ Formatted string like "field_name"
246
+ """
247
+ return format_field_key(key)
248
+
249
+ def _get_filtered_graph_data(self) -> GraphData:
250
+ """Get filtered graph data based on config filters.
251
+
252
+ Returns:
253
+ GraphData with only filtered nodes and edges
254
+ """
255
+ focus_key = self.config.get_feature_key()
256
+
257
+ # If no focus feature specified, return full graph
258
+ if focus_key is None:
259
+ return self.graph_data
260
+
261
+ # Use walker to extract subgraph
262
+ return self.walker.extract_subgraph(
263
+ focus_key=focus_key,
264
+ up=self.config.up,
265
+ down=self.config.down,
266
+ )
267
+
268
+ def render(self) -> str:
269
+ """Render the graph and return string output.
270
+
271
+ Returns:
272
+ Rendered graph as string
273
+ """
274
+ raise NotImplementedError