katalyst-engine 2.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.
- katalyst_engine/__init__.py +6 -0
- katalyst_engine/bundle/__init__.py +30 -0
- katalyst_engine/bundle/discovery.py +158 -0
- katalyst_engine/bundle/loader.py +134 -0
- katalyst_engine/bundle/protocol.py +209 -0
- katalyst_engine/core/__init__.py +62 -0
- katalyst_engine/core/compatibility.py +58 -0
- katalyst_engine/core/compositional.py +103 -0
- katalyst_engine/core/definitive.py +195 -0
- katalyst_engine/core/evolvable.py +89 -0
- katalyst_engine/core/identity.py +95 -0
- katalyst_engine/core/lifecycle.py +62 -0
- katalyst_engine/core/relation.py +151 -0
- katalyst_engine/core/version.py +203 -0
- katalyst_engine/discovery/__init__.py +20 -0
- katalyst_engine/discovery/declaration.py +74 -0
- katalyst_engine/discovery/dispatcher.py +83 -0
- katalyst_engine/discovery/protocol.py +69 -0
- katalyst_engine/events/__init__.py +10 -0
- katalyst_engine/events/bus.py +102 -0
- katalyst_engine/events/event.py +82 -0
- katalyst_engine/extensions/__init__.py +32 -0
- katalyst_engine/extensions/capability.py +45 -0
- katalyst_engine/extensions/discovery.py +85 -0
- katalyst_engine/extensions/effector.py +54 -0
- katalyst_engine/extensions/provider.py +33 -0
- katalyst_engine/extensions/registry.py +77 -0
- katalyst_engine/extensions/trigger.py +64 -0
- katalyst_engine/model/__init__.py +25 -0
- katalyst_engine/model/manager.py +85 -0
- katalyst_engine/model/materializer.py +78 -0
- katalyst_engine/model/node.py +49 -0
- katalyst_engine/model/query.py +186 -0
- katalyst_engine/model/store.py +119 -0
- katalyst_engine/py.typed +0 -0
- katalyst_engine/replication/__init__.py +30 -0
- katalyst_engine/replication/engine.py +104 -0
- katalyst_engine/replication/job.py +88 -0
- katalyst_engine/replication/transform.py +111 -0
- katalyst_engine/resolution/__init__.py +32 -0
- katalyst_engine/resolution/conflict.py +91 -0
- katalyst_engine/resolution/engine.py +131 -0
- katalyst_engine/resolution/strategies.py +122 -0
- katalyst_engine/schema/__init__.py +35 -0
- katalyst_engine/schema/definition.py +281 -0
- katalyst_engine/schema/manager.py +95 -0
- katalyst_engine/schema/registry.py +367 -0
- katalyst_engine/schema/versioning.py +115 -0
- katalyst_engine/snapshot/__init__.py +18 -0
- katalyst_engine/snapshot/diff.py +94 -0
- katalyst_engine/snapshot/snapshot.py +111 -0
- katalyst_engine/source/__init__.py +26 -0
- katalyst_engine/source/manifest.py +45 -0
- katalyst_engine/source/registry.py +122 -0
- katalyst_engine/source/source.py +92 -0
- katalyst_engine/toolkit/__init__.py +22 -0
- katalyst_engine/toolkit/file_ops.py +194 -0
- katalyst_engine/toolkit/rendering.py +58 -0
- katalyst_engine-2.0.0.dist-info/METADATA +50 -0
- katalyst_engine-2.0.0.dist-info/RECORD +61 -0
- katalyst_engine-2.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Schema manager — orchestrates schema registration and validation.
|
|
2
|
+
|
|
3
|
+
Coordinates the SchemaRegistry with schema definitions, providing
|
|
4
|
+
a high-level interface for registering schemas, validating nodes
|
|
5
|
+
against their schemas, and answering "what schema governs this kind?"
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from katalyst_engine.core.definitive import ValidationResult
|
|
11
|
+
from katalyst_engine.core.evolvable import Evolvable
|
|
12
|
+
from katalyst_engine.schema.definition import (
|
|
13
|
+
NodeDefinition,
|
|
14
|
+
RelationshipDefinition,
|
|
15
|
+
SchemaDefinition,
|
|
16
|
+
WingDefinition,
|
|
17
|
+
)
|
|
18
|
+
from katalyst_engine.schema.registry import SchemaRegistry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SchemaManager:
|
|
22
|
+
"""Orchestrator for schema registration and instance validation.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
manager = SchemaManager()
|
|
26
|
+
manager.register_schema(my_schema, node_defs, wing_defs, rel_defs)
|
|
27
|
+
result = manager.validate(some_node)
|
|
28
|
+
node_def = manager.definition_for_kind("layer")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._registry = SchemaRegistry()
|
|
33
|
+
self._schemas: dict[str, SchemaDefinition] = {} # keyed by api_version
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def registry(self) -> SchemaRegistry:
|
|
37
|
+
"""Access the underlying SchemaRegistry."""
|
|
38
|
+
return self._registry
|
|
39
|
+
|
|
40
|
+
def register_schema(
|
|
41
|
+
self,
|
|
42
|
+
schema: SchemaDefinition,
|
|
43
|
+
node_definitions: list[NodeDefinition] | None = None,
|
|
44
|
+
wing_definitions: list[WingDefinition] | None = None,
|
|
45
|
+
relationship_definitions: list[RelationshipDefinition] | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Register a schema and its associated definitions.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
schema: The top-level SchemaDefinition.
|
|
51
|
+
node_definitions: NodeDefinitions to register.
|
|
52
|
+
wing_definitions: WingDefinitions to register.
|
|
53
|
+
relationship_definitions: RelationshipDefinitions to register.
|
|
54
|
+
|
|
55
|
+
After registering explicit relationship definitions, calls
|
|
56
|
+
``derive_relationships_from_nodes()`` to auto-derive stubs for
|
|
57
|
+
any cross-reference rel_types not covered by the explicit set.
|
|
58
|
+
"""
|
|
59
|
+
self._schemas[schema.api_version] = schema
|
|
60
|
+
|
|
61
|
+
for node_def in node_definitions or []:
|
|
62
|
+
self._registry.register_node(node_def)
|
|
63
|
+
|
|
64
|
+
for wing_def in wing_definitions or []:
|
|
65
|
+
self._registry.register_wing(wing_def)
|
|
66
|
+
|
|
67
|
+
for rel_def in relationship_definitions or []:
|
|
68
|
+
self._registry.register_relationship(rel_def)
|
|
69
|
+
|
|
70
|
+
# Auto-derive stubs for any FieldRef.rel_type not yet registered
|
|
71
|
+
self._registry.derive_relationships_from_nodes()
|
|
72
|
+
|
|
73
|
+
def validate(self, instance: Evolvable) -> ValidationResult:
|
|
74
|
+
"""Validate an Evolvable against its kind's schema definition.
|
|
75
|
+
|
|
76
|
+
Returns a passing result if no definition is registered for the kind.
|
|
77
|
+
"""
|
|
78
|
+
return self._registry.validate_against_schema(instance)
|
|
79
|
+
|
|
80
|
+
def definition_for_kind(self, kind: str) -> NodeDefinition | None:
|
|
81
|
+
"""Look up the NodeDefinition governing a kind."""
|
|
82
|
+
return self._registry.get_node_definition(kind)
|
|
83
|
+
|
|
84
|
+
def schema_for_api_version(self, api_version: str) -> SchemaDefinition | None:
|
|
85
|
+
"""Look up a SchemaDefinition by its api_version string."""
|
|
86
|
+
return self._schemas.get(api_version)
|
|
87
|
+
|
|
88
|
+
def all_api_versions(self) -> list[str]:
|
|
89
|
+
"""Return all registered api_version strings, sorted."""
|
|
90
|
+
return sorted(self._schemas.keys())
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def schema_count(self) -> int:
|
|
94
|
+
"""Number of registered schemas."""
|
|
95
|
+
return len(self._schemas)
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Schema registry — runtime index of node definitions, wing lookups, and relationship types.
|
|
2
|
+
|
|
3
|
+
Maps kind strings to NodeDefinitions, provides wing membership
|
|
4
|
+
queries, enforces parent rules, and indexes relationship definitions.
|
|
5
|
+
This is the single source of truth for all node type and relationship
|
|
6
|
+
type metadata at runtime.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from katalyst_engine.core.definitive import ValidationResult
|
|
14
|
+
from katalyst_engine.core.evolvable import Evolvable
|
|
15
|
+
from katalyst_engine.schema.definition import (
|
|
16
|
+
NodeDefinition,
|
|
17
|
+
ParentRule,
|
|
18
|
+
RelationshipDefinition,
|
|
19
|
+
WingDefinition,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SchemaRegistry:
|
|
26
|
+
"""Runtime registry mapping kind strings to their definitions.
|
|
27
|
+
|
|
28
|
+
Provides fast lookups for:
|
|
29
|
+
- kind → NodeDefinition
|
|
30
|
+
- wing → member kinds
|
|
31
|
+
- kind → allowed parent kinds
|
|
32
|
+
- directory_name → kind (reverse lookup)
|
|
33
|
+
- target_kind → extension kinds
|
|
34
|
+
|
|
35
|
+
This is a mutable container populated by the SchemaManager
|
|
36
|
+
or directly by Bundle.register().
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._node_defs: dict[str, NodeDefinition] = {} # keyed by describes_kind
|
|
41
|
+
self._wing_defs: dict[str, WingDefinition] = {} # keyed by describes_kind
|
|
42
|
+
self._wing_members: dict[str, list[str]] = {} # wing name → kinds
|
|
43
|
+
self._by_directory: dict[str, str] = {} # directory_name → kind
|
|
44
|
+
self._extension_index: dict[str, list[str]] = {} # target_kind → extension kinds
|
|
45
|
+
self._rel_defs: dict[str, RelationshipDefinition] = {} # keyed by describes_kind
|
|
46
|
+
self._rel_by_wing: dict[str, list[str]] = {} # rel wing → rel kinds
|
|
47
|
+
self._rel_by_graph_label: dict[str, str] = {} # graph_label → describes_kind
|
|
48
|
+
|
|
49
|
+
# ── Registration ──────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def register_node(self, node_def: NodeDefinition) -> None:
|
|
52
|
+
"""Register a NodeDefinition, indexed by its describes_kind.
|
|
53
|
+
|
|
54
|
+
Updates all internal indexes (wing membership, directory lookup,
|
|
55
|
+
extension index). Re-registering the same kind overwrites cleanly.
|
|
56
|
+
"""
|
|
57
|
+
kind = node_def.describes_kind
|
|
58
|
+
self._node_defs[kind] = node_def
|
|
59
|
+
|
|
60
|
+
# Wing membership index
|
|
61
|
+
if node_def.wing:
|
|
62
|
+
if node_def.wing not in self._wing_members:
|
|
63
|
+
self._wing_members[node_def.wing] = []
|
|
64
|
+
# Remove stale entry if re-registering
|
|
65
|
+
self._wing_members[node_def.wing] = [
|
|
66
|
+
k for k in self._wing_members[node_def.wing] if k != kind
|
|
67
|
+
]
|
|
68
|
+
self._wing_members[node_def.wing].append(kind)
|
|
69
|
+
|
|
70
|
+
# Directory name reverse lookup
|
|
71
|
+
if node_def.directory_name:
|
|
72
|
+
self._by_directory[node_def.directory_name] = kind
|
|
73
|
+
|
|
74
|
+
# Extension index
|
|
75
|
+
if node_def.extends_kinds:
|
|
76
|
+
for target_kind in node_def.extends_kinds:
|
|
77
|
+
targets = self._extension_index.setdefault(target_kind, [])
|
|
78
|
+
if kind not in targets:
|
|
79
|
+
targets.append(kind)
|
|
80
|
+
|
|
81
|
+
def register_wing(self, wing_def: WingDefinition) -> None:
|
|
82
|
+
"""Register a WingDefinition."""
|
|
83
|
+
self._wing_defs[wing_def.describes_kind] = wing_def
|
|
84
|
+
|
|
85
|
+
def register_relationship(self, rel_def: RelationshipDefinition) -> None:
|
|
86
|
+
"""Register a RelationshipDefinition, indexed by describes_kind.
|
|
87
|
+
|
|
88
|
+
Updates wing and graph_label indexes. Re-registering the same
|
|
89
|
+
kind overwrites cleanly.
|
|
90
|
+
"""
|
|
91
|
+
kind = rel_def.describes_kind
|
|
92
|
+
self._rel_defs[kind] = rel_def
|
|
93
|
+
|
|
94
|
+
# Graph label reverse lookup
|
|
95
|
+
self._rel_by_graph_label[rel_def.graph_label] = kind
|
|
96
|
+
|
|
97
|
+
# Relationship wing index
|
|
98
|
+
if rel_def.wing:
|
|
99
|
+
if rel_def.wing not in self._rel_by_wing:
|
|
100
|
+
self._rel_by_wing[rel_def.wing] = []
|
|
101
|
+
# Remove stale entry if re-registering
|
|
102
|
+
self._rel_by_wing[rel_def.wing] = [
|
|
103
|
+
k for k in self._rel_by_wing[rel_def.wing] if k != kind
|
|
104
|
+
]
|
|
105
|
+
self._rel_by_wing[rel_def.wing].append(kind)
|
|
106
|
+
|
|
107
|
+
# ── Kind lookups ──────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def get_node_definition(self, kind: str) -> NodeDefinition | None:
|
|
110
|
+
"""Look up the NodeDefinition for a kind."""
|
|
111
|
+
return self._node_defs.get(kind)
|
|
112
|
+
|
|
113
|
+
def require_node_definition(self, kind: str) -> NodeDefinition:
|
|
114
|
+
"""Look up the NodeDefinition for a kind, raising if not found."""
|
|
115
|
+
node_def = self._node_defs.get(kind)
|
|
116
|
+
if node_def is None:
|
|
117
|
+
raise KeyError(f"Unknown node kind: {kind!r}")
|
|
118
|
+
return node_def
|
|
119
|
+
|
|
120
|
+
def __contains__(self, kind: str) -> bool:
|
|
121
|
+
"""Check if a kind is registered."""
|
|
122
|
+
return kind in self._node_defs
|
|
123
|
+
|
|
124
|
+
# ── Wing lookups ──────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def get_wing_definition(self, wing: str) -> WingDefinition | None:
|
|
127
|
+
"""Look up a WingDefinition by its describes_kind."""
|
|
128
|
+
return self._wing_defs.get(wing)
|
|
129
|
+
|
|
130
|
+
def kinds_in_wing(self, wing: str) -> list[str]:
|
|
131
|
+
"""Return all node kinds belonging to a wing, sorted by display order."""
|
|
132
|
+
kinds = self._wing_members.get(wing, [])
|
|
133
|
+
# Sort by display_order of the registered NodeDefinition
|
|
134
|
+
return sorted(
|
|
135
|
+
kinds,
|
|
136
|
+
key=lambda k: self._node_defs[k].display_order if k in self._node_defs else 0,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def all_kinds(self) -> list[str]:
|
|
140
|
+
"""Return all registered node kind strings, sorted alphabetically."""
|
|
141
|
+
return sorted(self._node_defs.keys())
|
|
142
|
+
|
|
143
|
+
def sorted_kinds(self) -> list[str]:
|
|
144
|
+
"""Return all kinds sorted by display order."""
|
|
145
|
+
return [
|
|
146
|
+
nd.describes_kind
|
|
147
|
+
for nd in sorted(self._node_defs.values(), key=lambda nd: nd.display_order)
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
def all_wings(self) -> list[str]:
|
|
151
|
+
"""Return all registered wing names (from wing_members index), sorted."""
|
|
152
|
+
return sorted(self._wing_members.keys())
|
|
153
|
+
|
|
154
|
+
# ── Type classification queries ───────────────────────────────────
|
|
155
|
+
|
|
156
|
+
def is_root(self, kind: str) -> bool:
|
|
157
|
+
"""Whether this kind is a root node (no parent required)."""
|
|
158
|
+
nd = self._node_defs.get(kind)
|
|
159
|
+
return nd.is_root if nd else False
|
|
160
|
+
|
|
161
|
+
def is_leaf(self, kind: str) -> bool:
|
|
162
|
+
"""Whether this kind is a leaf node (no children)."""
|
|
163
|
+
nd = self._node_defs.get(kind)
|
|
164
|
+
return nd.is_leaf if nd else False
|
|
165
|
+
|
|
166
|
+
def is_extension(self, kind: str) -> bool:
|
|
167
|
+
"""Whether this kind is an extension type."""
|
|
168
|
+
nd = self._node_defs.get(kind)
|
|
169
|
+
return nd.is_extension if nd else False
|
|
170
|
+
|
|
171
|
+
def is_scaffoldable(self, kind: str) -> bool:
|
|
172
|
+
"""Whether this kind supports project scaffolding."""
|
|
173
|
+
nd = self._node_defs.get(kind)
|
|
174
|
+
return nd.scaffoldable if nd else False
|
|
175
|
+
|
|
176
|
+
def is_clonable(self, kind: str) -> bool:
|
|
177
|
+
"""Whether this kind can be cloned."""
|
|
178
|
+
nd = self._node_defs.get(kind)
|
|
179
|
+
return nd.clonable if nd else False
|
|
180
|
+
|
|
181
|
+
def scaffoldable_kinds(self) -> list[str]:
|
|
182
|
+
"""Return all kinds that support scaffolding, sorted by display_order."""
|
|
183
|
+
return [
|
|
184
|
+
nd.describes_kind
|
|
185
|
+
for nd in sorted(self._node_defs.values(), key=lambda d: d.display_order)
|
|
186
|
+
if nd.scaffoldable
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
def clonable_kinds(self) -> list[str]:
|
|
190
|
+
"""Return all kinds that can be cloned, sorted by display_order."""
|
|
191
|
+
return [
|
|
192
|
+
nd.describes_kind
|
|
193
|
+
for nd in sorted(self._node_defs.values(), key=lambda d: d.display_order)
|
|
194
|
+
if nd.clonable
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
def creatable_kinds(self) -> list[str]:
|
|
198
|
+
"""Return all non-extension kinds that can be created, sorted by display_order."""
|
|
199
|
+
return [
|
|
200
|
+
nd.describes_kind
|
|
201
|
+
for nd in sorted(self._node_defs.values(), key=lambda d: d.display_order)
|
|
202
|
+
if not nd.is_extension
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
def display_label_for(self, kind: str) -> str:
|
|
206
|
+
"""Return the display label for a kind, with fallback to title-cased kind."""
|
|
207
|
+
nd = self._node_defs.get(kind)
|
|
208
|
+
if nd is not None:
|
|
209
|
+
return nd.effective_display_label
|
|
210
|
+
return kind.replace("_", " ").title()
|
|
211
|
+
|
|
212
|
+
def display_label_plural_for(self, kind: str) -> str:
|
|
213
|
+
"""Return the plural display label for a kind."""
|
|
214
|
+
nd = self._node_defs.get(kind)
|
|
215
|
+
if nd is not None:
|
|
216
|
+
return nd.effective_display_label_plural
|
|
217
|
+
return kind.replace("_", " ").title() + "s"
|
|
218
|
+
|
|
219
|
+
# ── Parent / child queries ────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def parent_rules_for(self, kind: str) -> tuple[ParentRule, ...]:
|
|
222
|
+
"""Return parent rules for a node kind."""
|
|
223
|
+
node_def = self._node_defs.get(kind)
|
|
224
|
+
if node_def is None:
|
|
225
|
+
return ()
|
|
226
|
+
return node_def.parent_rules
|
|
227
|
+
|
|
228
|
+
def allowed_parent_kinds(self, kind: str) -> frozenset[str]:
|
|
229
|
+
"""Return the set of kinds allowed as parents for the given kind.
|
|
230
|
+
|
|
231
|
+
Returns an empty frozenset if unconstrained or kind is unknown.
|
|
232
|
+
"""
|
|
233
|
+
nd = self._node_defs.get(kind)
|
|
234
|
+
if nd is None:
|
|
235
|
+
return frozenset()
|
|
236
|
+
return nd.allowed_parent_kinds
|
|
237
|
+
|
|
238
|
+
# ── Extension queries ─────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
def extensions_for_kind(self, target_kind: str) -> list[str]:
|
|
241
|
+
"""Return extension kinds that can target the given kind."""
|
|
242
|
+
return list(self._extension_index.get(target_kind, []))
|
|
243
|
+
|
|
244
|
+
# ── Directory reverse lookup ──────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
def kind_for_directory(self, directory_name: str) -> str | None:
|
|
247
|
+
"""Reverse lookup: directory name → kind."""
|
|
248
|
+
return self._by_directory.get(directory_name)
|
|
249
|
+
|
|
250
|
+
# ── Validation ────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def validate_against_schema(self, instance: Evolvable) -> ValidationResult:
|
|
253
|
+
"""Validate an Evolvable against its kind's NodeDefinition.
|
|
254
|
+
|
|
255
|
+
Returns a passing result if no definition is registered for the kind.
|
|
256
|
+
"""
|
|
257
|
+
node_def = self._node_defs.get(instance.identity.kind)
|
|
258
|
+
if node_def is None:
|
|
259
|
+
return ValidationResult(valid=True)
|
|
260
|
+
return node_def.validate_instance(instance)
|
|
261
|
+
|
|
262
|
+
# ── Relationship lookups ──────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
def get_relationship(self, kind: str) -> RelationshipDefinition | None:
|
|
265
|
+
"""Look up a RelationshipDefinition by its describes_kind."""
|
|
266
|
+
return self._rel_defs.get(kind)
|
|
267
|
+
|
|
268
|
+
def get_relationship_by_graph_label(self, label: str) -> RelationshipDefinition | None:
|
|
269
|
+
"""Look up a RelationshipDefinition by its SCREAMING_SNAKE graph label."""
|
|
270
|
+
kind = self._rel_by_graph_label.get(label)
|
|
271
|
+
if kind is None:
|
|
272
|
+
return None
|
|
273
|
+
return self._rel_defs.get(kind)
|
|
274
|
+
|
|
275
|
+
def all_relationships(self) -> list[RelationshipDefinition]:
|
|
276
|
+
"""Return all registered RelationshipDefinitions, sorted by display_order."""
|
|
277
|
+
return sorted(self._rel_defs.values(), key=lambda rd: rd.display_order)
|
|
278
|
+
|
|
279
|
+
def relationships_in_wing(self, wing: str) -> list[RelationshipDefinition]:
|
|
280
|
+
"""Return RelationshipDefinitions in a wing, sorted by display_order."""
|
|
281
|
+
kinds = self._rel_by_wing.get(wing, [])
|
|
282
|
+
defs = [self._rel_defs[k] for k in kinds if k in self._rel_defs]
|
|
283
|
+
return sorted(defs, key=lambda rd: rd.display_order)
|
|
284
|
+
|
|
285
|
+
def relationship_wings(self) -> list[str]:
|
|
286
|
+
"""Return all registered relationship wing names, sorted."""
|
|
287
|
+
return sorted(self._rel_by_wing.keys())
|
|
288
|
+
|
|
289
|
+
# ── Relationship derivation ───────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
def derive_relationships_from_nodes(self) -> list[str]:
|
|
292
|
+
"""Scan all registered NodeDefinitions for cross-reference rel_types.
|
|
293
|
+
|
|
294
|
+
For any FieldRef.rel_type that is not already registered as a
|
|
295
|
+
RelationshipDefinition, creates a stub definition (no wing, no
|
|
296
|
+
description) and logs a warning. Returns the list of auto-derived
|
|
297
|
+
kind strings.
|
|
298
|
+
|
|
299
|
+
This enables a "derive, don't duplicate" workflow: bundles
|
|
300
|
+
declare enriched RelationshipDefinitions for classified types,
|
|
301
|
+
and this method fills in stubs for any that were missed.
|
|
302
|
+
"""
|
|
303
|
+
from katalyst_engine.core.identity import Identity
|
|
304
|
+
|
|
305
|
+
derived: list[str] = []
|
|
306
|
+
seen_rel_types: set[str] = set()
|
|
307
|
+
|
|
308
|
+
for node_def in self._node_defs.values():
|
|
309
|
+
for ref in node_def.cross_references:
|
|
310
|
+
if not ref.rel_type:
|
|
311
|
+
continue
|
|
312
|
+
# Normalise to snake_case canonical kind
|
|
313
|
+
canonical = ref.rel_type.lower()
|
|
314
|
+
if canonical in seen_rel_types:
|
|
315
|
+
continue
|
|
316
|
+
seen_rel_types.add(canonical)
|
|
317
|
+
|
|
318
|
+
if canonical not in self._rel_defs:
|
|
319
|
+
stub = RelationshipDefinition(
|
|
320
|
+
identity=Identity(
|
|
321
|
+
kind="relationship_definition",
|
|
322
|
+
name=canonical,
|
|
323
|
+
),
|
|
324
|
+
describes_kind=canonical,
|
|
325
|
+
description=f"Auto-derived from FieldRef.rel_type={ref.rel_type!r}",
|
|
326
|
+
)
|
|
327
|
+
self.register_relationship(stub)
|
|
328
|
+
derived.append(canonical)
|
|
329
|
+
logger.warning(
|
|
330
|
+
"Auto-derived RelationshipDefinition for %r "
|
|
331
|
+
"(no explicit definition registered)",
|
|
332
|
+
canonical,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return derived
|
|
336
|
+
|
|
337
|
+
# ── Counts / iteration ────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def node_count(self) -> int:
|
|
341
|
+
"""Number of registered node definitions."""
|
|
342
|
+
return len(self._node_defs)
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def wing_count(self) -> int:
|
|
346
|
+
"""Number of registered wing definitions."""
|
|
347
|
+
return len(self._wing_defs)
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def relationship_count(self) -> int:
|
|
351
|
+
"""Number of registered relationship definitions."""
|
|
352
|
+
return len(self._rel_defs)
|
|
353
|
+
|
|
354
|
+
def all_node_definitions(self) -> list[NodeDefinition]:
|
|
355
|
+
"""Return all registered NodeDefinitions, sorted by describes_kind."""
|
|
356
|
+
return sorted(self._node_defs.values(), key=lambda nd: nd.describes_kind)
|
|
357
|
+
|
|
358
|
+
def clear(self) -> None:
|
|
359
|
+
"""Remove all registered definitions."""
|
|
360
|
+
self._node_defs.clear()
|
|
361
|
+
self._wing_defs.clear()
|
|
362
|
+
self._wing_members.clear()
|
|
363
|
+
self._by_directory.clear()
|
|
364
|
+
self._extension_index.clear()
|
|
365
|
+
self._rel_defs.clear()
|
|
366
|
+
self._rel_by_wing.clear()
|
|
367
|
+
self._rel_by_graph_label.clear()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Schema versioning — comparison and migration path planning.
|
|
2
|
+
|
|
3
|
+
Provides helpers for comparing schema versions and computing the
|
|
4
|
+
sequence of MigrationPaths needed to move between versions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from katalyst_engine.core.compatibility import MigrationPath
|
|
14
|
+
from katalyst_engine.core.version import Version
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SchemaVersionChange(str, Enum):
|
|
18
|
+
"""Classification of the change between two schema versions."""
|
|
19
|
+
|
|
20
|
+
NONE = "none"
|
|
21
|
+
PATCH = "patch"
|
|
22
|
+
MINOR = "minor"
|
|
23
|
+
MAJOR = "major"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SchemaVersionComparison(BaseModel, frozen=True):
|
|
27
|
+
"""Result of comparing two schema versions.
|
|
28
|
+
|
|
29
|
+
Captures the direction and magnitude of the change, and whether
|
|
30
|
+
the versions are compatible.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from_version: Version
|
|
34
|
+
to_version: Version
|
|
35
|
+
change: SchemaVersionChange
|
|
36
|
+
compatible: bool
|
|
37
|
+
"""Whether the two versions are semver-compatible."""
|
|
38
|
+
|
|
39
|
+
upgrade: bool
|
|
40
|
+
"""True if to_version > from_version."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def compare_versions(from_v: Version, to_v: Version) -> SchemaVersionComparison:
|
|
44
|
+
"""Compare two schema versions and classify the change.
|
|
45
|
+
|
|
46
|
+
Returns a SchemaVersionComparison describing the magnitude,
|
|
47
|
+
direction, and compatibility of the change.
|
|
48
|
+
"""
|
|
49
|
+
if from_v == to_v:
|
|
50
|
+
change = SchemaVersionChange.NONE
|
|
51
|
+
elif from_v.major != to_v.major:
|
|
52
|
+
change = SchemaVersionChange.MAJOR
|
|
53
|
+
elif from_v.minor != to_v.minor:
|
|
54
|
+
change = SchemaVersionChange.MINOR
|
|
55
|
+
else:
|
|
56
|
+
change = SchemaVersionChange.PATCH
|
|
57
|
+
|
|
58
|
+
return SchemaVersionComparison(
|
|
59
|
+
from_version=from_v,
|
|
60
|
+
to_version=to_v,
|
|
61
|
+
change=change,
|
|
62
|
+
compatible=from_v.is_compatible_with(to_v),
|
|
63
|
+
upgrade=to_v > from_v,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def plan_migration_path(
|
|
68
|
+
from_version: Version,
|
|
69
|
+
to_version: Version,
|
|
70
|
+
available_paths: list[MigrationPath],
|
|
71
|
+
) -> list[MigrationPath]:
|
|
72
|
+
"""Find an ordered sequence of migrations from one version to another.
|
|
73
|
+
|
|
74
|
+
Uses a simple BFS over available MigrationPaths. Returns an empty
|
|
75
|
+
list if no path exists.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
from_version: Starting version.
|
|
79
|
+
to_version: Target version.
|
|
80
|
+
available_paths: All known migration paths.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Ordered list of MigrationPaths from from_version to to_version,
|
|
84
|
+
or empty list if unreachable.
|
|
85
|
+
"""
|
|
86
|
+
if from_version == to_version:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
adjacency: dict[str, list[MigrationPath]] = {}
|
|
90
|
+
for path in available_paths:
|
|
91
|
+
key = str(path.from_version)
|
|
92
|
+
if key not in adjacency:
|
|
93
|
+
adjacency[key] = []
|
|
94
|
+
adjacency[key].append(path)
|
|
95
|
+
|
|
96
|
+
visited: set[str] = set()
|
|
97
|
+
queue: list[list[MigrationPath]] = [[]]
|
|
98
|
+
current_versions = [str(from_version)]
|
|
99
|
+
|
|
100
|
+
while queue:
|
|
101
|
+
chain = queue.pop(0)
|
|
102
|
+
current = current_versions.pop(0)
|
|
103
|
+
|
|
104
|
+
if current in visited:
|
|
105
|
+
continue
|
|
106
|
+
visited.add(current)
|
|
107
|
+
|
|
108
|
+
for path in adjacency.get(current, []):
|
|
109
|
+
new_chain = [*chain, path]
|
|
110
|
+
if str(path.to_version) == str(to_version):
|
|
111
|
+
return new_chain
|
|
112
|
+
queue.append(new_chain)
|
|
113
|
+
current_versions.append(str(path.to_version))
|
|
114
|
+
|
|
115
|
+
return []
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Snapshot — frozen model copies and diffing.
|
|
2
|
+
|
|
3
|
+
.. note:: FUTURE: Wire into taxonomy when audit trail / change history features
|
|
4
|
+
are built. Snapshots are frozen model copies that can be diffed.
|
|
5
|
+
Current taxonomy sync uses sync_id pruning (database-native, more
|
|
6
|
+
efficient for the sync use case). Snapshots serve "show me what
|
|
7
|
+
changed between two points in time" which is a different need.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from katalyst_engine.snapshot.diff import SnapshotDiff, diff_snapshots
|
|
11
|
+
from katalyst_engine.snapshot.snapshot import Snapshot, SnapshotManifest
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Snapshot",
|
|
15
|
+
"SnapshotDiff",
|
|
16
|
+
"SnapshotManifest",
|
|
17
|
+
"diff_snapshots",
|
|
18
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Snapshot diff — comparison of two snapshots.
|
|
2
|
+
|
|
3
|
+
Computes the set of identities added, removed, and changed
|
|
4
|
+
between two snapshots.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from katalyst_engine.core.identity import Identity
|
|
12
|
+
from katalyst_engine.snapshot.snapshot import Snapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SnapshotDiff(BaseModel, frozen=True):
|
|
16
|
+
"""Comparison of two snapshots showing what changed.
|
|
17
|
+
|
|
18
|
+
Captures which identities were added, removed, are present
|
|
19
|
+
in both, or have changed content (different checksums).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from_snapshot: Identity
|
|
23
|
+
"""Identity of the older snapshot."""
|
|
24
|
+
|
|
25
|
+
to_snapshot: Identity
|
|
26
|
+
"""Identity of the newer snapshot."""
|
|
27
|
+
|
|
28
|
+
added: frozenset[Identity] = frozenset()
|
|
29
|
+
"""Identities present in the newer snapshot but not the older."""
|
|
30
|
+
|
|
31
|
+
removed: frozenset[Identity] = frozenset()
|
|
32
|
+
"""Identities present in the older snapshot but not the newer."""
|
|
33
|
+
|
|
34
|
+
common: frozenset[Identity] = frozenset()
|
|
35
|
+
"""Identities present in both snapshots (may have changed)."""
|
|
36
|
+
|
|
37
|
+
changed: frozenset[Identity] = frozenset()
|
|
38
|
+
"""Identities whose content checksums differ between snapshots."""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def has_changes(self) -> bool:
|
|
42
|
+
"""True if any identities were added, removed, or changed."""
|
|
43
|
+
return bool(self.added) or bool(self.removed) or bool(self.changed)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def added_count(self) -> int:
|
|
47
|
+
"""Number of added identities."""
|
|
48
|
+
return len(self.added)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def removed_count(self) -> int:
|
|
52
|
+
"""Number of removed identities."""
|
|
53
|
+
return len(self.removed)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def changed_count(self) -> int:
|
|
57
|
+
"""Number of changed identities."""
|
|
58
|
+
return len(self.changed)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def diff_snapshots(older: Snapshot, newer: Snapshot) -> SnapshotDiff:
|
|
62
|
+
"""Compute the diff between two snapshots.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
older: The baseline snapshot.
|
|
66
|
+
newer: The comparison snapshot.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A SnapshotDiff describing what changed.
|
|
70
|
+
"""
|
|
71
|
+
older_set = frozenset(older.member_refs)
|
|
72
|
+
newer_set = frozenset(newer.member_refs)
|
|
73
|
+
|
|
74
|
+
common = older_set & newer_set
|
|
75
|
+
|
|
76
|
+
# Compute content-changed identities when both have checksums
|
|
77
|
+
changed: frozenset[Identity] = frozenset()
|
|
78
|
+
older_checksums = older.manifest.node_checksums
|
|
79
|
+
newer_checksums = newer.manifest.node_checksums
|
|
80
|
+
if older_checksums and newer_checksums:
|
|
81
|
+
changed = frozenset(
|
|
82
|
+
ident
|
|
83
|
+
for ident in common
|
|
84
|
+
if older_checksums.get(ident.fqn) != newer_checksums.get(ident.fqn)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return SnapshotDiff(
|
|
88
|
+
from_snapshot=older.identity,
|
|
89
|
+
to_snapshot=newer.identity,
|
|
90
|
+
added=newer_set - older_set,
|
|
91
|
+
removed=older_set - newer_set,
|
|
92
|
+
common=common,
|
|
93
|
+
changed=changed,
|
|
94
|
+
)
|