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,203 @@
|
|
|
1
|
+
"""Semantic versioning with ranges and constraints.
|
|
2
|
+
|
|
3
|
+
Immutable version objects supporting comparison, parsing, and
|
|
4
|
+
semver-compatible range checking. Pre-release versions sort
|
|
5
|
+
before their release counterpart: 1.0.0-alpha < 1.0.0-beta < 1.0.0.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
_VERSION_RE = re.compile(
|
|
15
|
+
r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
|
|
16
|
+
r"(?:-(?P<pre>[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*))?$"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Version(BaseModel, frozen=True):
|
|
21
|
+
"""Semantic version with optional pre-release tag.
|
|
22
|
+
|
|
23
|
+
Immutable. Two versions of the same artifact are two distinct
|
|
24
|
+
Evolvable instances connected by a MigrationPath.
|
|
25
|
+
|
|
26
|
+
Supports comparison operators for sorting and range checks.
|
|
27
|
+
Pre-release versions sort before their release counterpart:
|
|
28
|
+
1.0.0-alpha < 1.0.0-beta < 1.0.0
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
major: int = 0
|
|
32
|
+
minor: int = 0
|
|
33
|
+
patch: int = 0
|
|
34
|
+
pre: str | None = None
|
|
35
|
+
"""Pre-release tag, e.g. "alpha", "beta", "rc.1"."""
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
"""Format as '1.2.3' or '1.2.3-alpha'."""
|
|
39
|
+
base = f"{self.major}.{self.minor}.{self.patch}"
|
|
40
|
+
if self.pre:
|
|
41
|
+
return f"{base}-{self.pre}"
|
|
42
|
+
return base
|
|
43
|
+
|
|
44
|
+
def _cmp_tuple(self) -> tuple[int, int, int, int, str]:
|
|
45
|
+
"""Comparison tuple. Pre-release sorts before release (release = ~).
|
|
46
|
+
|
|
47
|
+
The tilde character (~) sorts after all alphanumeric strings,
|
|
48
|
+
so a release version always compares greater than any pre-release.
|
|
49
|
+
"""
|
|
50
|
+
if self.pre is None:
|
|
51
|
+
return (self.major, self.minor, self.patch, 1, "")
|
|
52
|
+
return (self.major, self.minor, self.patch, 0, self.pre)
|
|
53
|
+
|
|
54
|
+
def __lt__(self, other: object) -> bool:
|
|
55
|
+
if not isinstance(other, Version):
|
|
56
|
+
return NotImplemented
|
|
57
|
+
return self._cmp_tuple() < other._cmp_tuple()
|
|
58
|
+
|
|
59
|
+
def __le__(self, other: object) -> bool:
|
|
60
|
+
if not isinstance(other, Version):
|
|
61
|
+
return NotImplemented
|
|
62
|
+
return self._cmp_tuple() <= other._cmp_tuple()
|
|
63
|
+
|
|
64
|
+
def __gt__(self, other: object) -> bool:
|
|
65
|
+
if not isinstance(other, Version):
|
|
66
|
+
return NotImplemented
|
|
67
|
+
return self._cmp_tuple() > other._cmp_tuple()
|
|
68
|
+
|
|
69
|
+
def __ge__(self, other: object) -> bool:
|
|
70
|
+
if not isinstance(other, Version):
|
|
71
|
+
return NotImplemented
|
|
72
|
+
return self._cmp_tuple() >= other._cmp_tuple()
|
|
73
|
+
|
|
74
|
+
def is_compatible_with(self, other: Version) -> bool:
|
|
75
|
+
"""True if same major version (semver compatibility assumption).
|
|
76
|
+
|
|
77
|
+
Version 0.x is only compatible with itself (0.x is unstable).
|
|
78
|
+
"""
|
|
79
|
+
if self.major == 0 or other.major == 0:
|
|
80
|
+
return self.major == other.major and self.minor == other.minor
|
|
81
|
+
return self.major == other.major
|
|
82
|
+
|
|
83
|
+
def next_major(self) -> Version:
|
|
84
|
+
"""Return the next major version (e.g. 1.2.3 → 2.0.0)."""
|
|
85
|
+
return Version(major=self.major + 1)
|
|
86
|
+
|
|
87
|
+
def next_minor(self) -> Version:
|
|
88
|
+
"""Return the next minor version (e.g. 1.2.3 → 1.3.0)."""
|
|
89
|
+
return Version(major=self.major, minor=self.minor + 1)
|
|
90
|
+
|
|
91
|
+
def next_patch(self) -> Version:
|
|
92
|
+
"""Return the next patch version (e.g. 1.2.3 → 1.2.4)."""
|
|
93
|
+
return Version(major=self.major, minor=self.minor, patch=self.patch + 1)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def parse(cls, s: str) -> Version:
|
|
97
|
+
"""Parse version strings like '1.2.3', '1.2.3-alpha', '0.1.0'.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: If the string doesn't match the expected format.
|
|
101
|
+
"""
|
|
102
|
+
m = _VERSION_RE.match(s.strip())
|
|
103
|
+
if not m:
|
|
104
|
+
raise ValueError(f"Invalid version string: {s!r}")
|
|
105
|
+
return cls(
|
|
106
|
+
major=int(m.group("major")),
|
|
107
|
+
minor=int(m.group("minor")),
|
|
108
|
+
patch=int(m.group("patch")),
|
|
109
|
+
pre=m.group("pre"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class VersionRange(BaseModel, frozen=True):
|
|
114
|
+
"""A range constraint on versions.
|
|
115
|
+
|
|
116
|
+
Used by CanonicityClaims and dependency declarations
|
|
117
|
+
to express "I support versions >=1.0, <2.0".
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
min_version: Version | None = None
|
|
121
|
+
max_version: Version | None = None
|
|
122
|
+
include_min: bool = True
|
|
123
|
+
include_max: bool = False
|
|
124
|
+
|
|
125
|
+
def contains(self, v: Version) -> bool:
|
|
126
|
+
"""Check if a version falls within this range."""
|
|
127
|
+
if self.min_version is not None:
|
|
128
|
+
if self.include_min:
|
|
129
|
+
if v < self.min_version:
|
|
130
|
+
return False
|
|
131
|
+
else:
|
|
132
|
+
if v <= self.min_version:
|
|
133
|
+
return False
|
|
134
|
+
if self.max_version is not None:
|
|
135
|
+
if self.include_max:
|
|
136
|
+
if v > self.max_version:
|
|
137
|
+
return False
|
|
138
|
+
else:
|
|
139
|
+
if v >= self.max_version:
|
|
140
|
+
return False
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def parse(cls, s: str) -> VersionRange:
|
|
145
|
+
"""Parse version range strings.
|
|
146
|
+
|
|
147
|
+
Supported formats:
|
|
148
|
+
'*' → unbounded (matches everything)
|
|
149
|
+
'==1.0.0' → exact match (min=max, both inclusive)
|
|
150
|
+
'>=1.0.0' → lower-bounded
|
|
151
|
+
'>=1.0.0,<2.0.0' → bounded range
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ValueError: If the string doesn't match any expected format.
|
|
155
|
+
"""
|
|
156
|
+
s = s.strip()
|
|
157
|
+
if s == "*":
|
|
158
|
+
return cls()
|
|
159
|
+
|
|
160
|
+
if s.startswith("=="):
|
|
161
|
+
v = Version.parse(s[2:])
|
|
162
|
+
return cls(min_version=v, max_version=v, include_min=True, include_max=True)
|
|
163
|
+
|
|
164
|
+
parts = [p.strip() for p in s.split(",")]
|
|
165
|
+
min_v: Version | None = None
|
|
166
|
+
max_v: Version | None = None
|
|
167
|
+
inc_min = True
|
|
168
|
+
inc_max = False
|
|
169
|
+
|
|
170
|
+
for part in parts:
|
|
171
|
+
if part.startswith(">="):
|
|
172
|
+
min_v = Version.parse(part[2:])
|
|
173
|
+
inc_min = True
|
|
174
|
+
elif part.startswith(">"):
|
|
175
|
+
min_v = Version.parse(part[1:])
|
|
176
|
+
inc_min = False
|
|
177
|
+
elif part.startswith("<="):
|
|
178
|
+
max_v = Version.parse(part[2:])
|
|
179
|
+
inc_max = True
|
|
180
|
+
elif part.startswith("<"):
|
|
181
|
+
max_v = Version.parse(part[1:])
|
|
182
|
+
inc_max = False
|
|
183
|
+
else:
|
|
184
|
+
raise ValueError(f"Invalid version range component: {part!r}")
|
|
185
|
+
|
|
186
|
+
return cls(
|
|
187
|
+
min_version=min_v,
|
|
188
|
+
max_version=max_v,
|
|
189
|
+
include_min=inc_min,
|
|
190
|
+
include_max=inc_max,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class VersionConstraint(BaseModel, frozen=True):
|
|
195
|
+
"""Named version constraint for dependency declarations.
|
|
196
|
+
|
|
197
|
+
Binds a name (identifying what is constrained) to a version range.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
name: str
|
|
201
|
+
"""What this constrains (e.g. "api_version", "schema_version")."""
|
|
202
|
+
|
|
203
|
+
range: VersionRange
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Discovery — finding declarations from sources.
|
|
2
|
+
|
|
3
|
+
.. note:: FUTURE: Wire into taxonomy when multi-source node discovery is implemented.
|
|
4
|
+
The taxonomy's filesystem discovery strategies would be refactored into a
|
|
5
|
+
FilesystemDiscoveryProtocol that returns Declaration objects. Pairs with
|
|
6
|
+
source/ and resolution/ as a coherent pipeline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from katalyst_engine.discovery.declaration import Cursor, Declaration, Format
|
|
10
|
+
from katalyst_engine.discovery.dispatcher import DiscoveryDispatcher
|
|
11
|
+
from katalyst_engine.discovery.protocol import DiscoveryProtocol, DiscoveryResult
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Cursor",
|
|
15
|
+
"Declaration",
|
|
16
|
+
"DiscoveryDispatcher",
|
|
17
|
+
"DiscoveryProtocol",
|
|
18
|
+
"DiscoveryResult",
|
|
19
|
+
"Format",
|
|
20
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Declarations — raw data discovered from sources.
|
|
2
|
+
|
|
3
|
+
A Declaration is the raw, unresolved data unit discovered from a source.
|
|
4
|
+
It carries format metadata and a cursor for incremental discovery.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from katalyst_engine.core.identity import Identity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Format(str, Enum):
|
|
18
|
+
"""Serialization format of a discovered declaration."""
|
|
19
|
+
|
|
20
|
+
YAML = "yaml"
|
|
21
|
+
JSON = "json"
|
|
22
|
+
TOML = "toml"
|
|
23
|
+
MARKDOWN = "markdown"
|
|
24
|
+
BINARY = "binary"
|
|
25
|
+
UNKNOWN = "unknown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Cursor(BaseModel, frozen=True):
|
|
29
|
+
"""Opaque pagination/position marker for incremental discovery.
|
|
30
|
+
|
|
31
|
+
Sources use cursors to resume discovery from a previous position.
|
|
32
|
+
The engine treats cursor values as opaque — only the source
|
|
33
|
+
implementation interprets them.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
value: str = ""
|
|
37
|
+
"""Opaque position marker."""
|
|
38
|
+
|
|
39
|
+
source_fqn: str = ""
|
|
40
|
+
"""FQN of the source this cursor belongs to."""
|
|
41
|
+
|
|
42
|
+
def is_empty(self) -> bool:
|
|
43
|
+
"""True if this is a fresh/empty cursor."""
|
|
44
|
+
return self.value == ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Declaration(BaseModel, frozen=True):
|
|
48
|
+
"""A raw, unresolved data unit discovered from a source.
|
|
49
|
+
|
|
50
|
+
Declarations are the input to the resolution engine. They carry
|
|
51
|
+
raw spec data in whatever format the source provides, plus
|
|
52
|
+
metadata about where they came from.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
identity: Identity
|
|
56
|
+
"""Provisional identity assigned during discovery."""
|
|
57
|
+
|
|
58
|
+
source_fqn: str
|
|
59
|
+
"""FQN of the source that produced this declaration."""
|
|
60
|
+
|
|
61
|
+
format: Format = Format.YAML
|
|
62
|
+
"""Serialization format of the raw_data."""
|
|
63
|
+
|
|
64
|
+
raw_data: dict[str, Any] = {}
|
|
65
|
+
"""The raw spec/content as parsed key-value data."""
|
|
66
|
+
|
|
67
|
+
raw_text: str = ""
|
|
68
|
+
"""The original text content, if available."""
|
|
69
|
+
|
|
70
|
+
checksum: str = ""
|
|
71
|
+
"""Content hash for change detection."""
|
|
72
|
+
|
|
73
|
+
cursor: Cursor = Cursor()
|
|
74
|
+
"""Position marker for incremental discovery."""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Discovery dispatcher — routes discovery to correct protocol.
|
|
2
|
+
|
|
3
|
+
The dispatcher maintains a registry of DiscoveryProtocol implementations
|
|
4
|
+
and routes discovery requests to the appropriate protocol based on
|
|
5
|
+
source kind.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from katalyst_engine.discovery.declaration import Cursor, Declaration
|
|
11
|
+
from katalyst_engine.discovery.protocol import DiscoveryProtocol, DiscoveryResult
|
|
12
|
+
from katalyst_engine.source.source import Source
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DiscoveryDispatcher:
|
|
16
|
+
"""Routes discovery requests to the correct protocol implementation.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
dispatcher = DiscoveryDispatcher()
|
|
20
|
+
dispatcher.register(MyFilesystemProtocol())
|
|
21
|
+
result = dispatcher.discover(filesystem_source)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._protocols: list[DiscoveryProtocol] = []
|
|
26
|
+
|
|
27
|
+
def register(self, protocol: DiscoveryProtocol) -> None:
|
|
28
|
+
"""Register a discovery protocol implementation."""
|
|
29
|
+
self._protocols.append(protocol)
|
|
30
|
+
|
|
31
|
+
def discover(
|
|
32
|
+
self,
|
|
33
|
+
source: Source,
|
|
34
|
+
cursor: Cursor | None = None,
|
|
35
|
+
) -> DiscoveryResult:
|
|
36
|
+
"""Dispatch discovery to the first matching protocol.
|
|
37
|
+
|
|
38
|
+
Tries each registered protocol in order. The first one that
|
|
39
|
+
returns True from supports() is used.
|
|
40
|
+
|
|
41
|
+
Returns an empty result with an error if no protocol matches.
|
|
42
|
+
"""
|
|
43
|
+
for protocol in self._protocols:
|
|
44
|
+
if protocol.supports(source):
|
|
45
|
+
return protocol.discover(source, cursor)
|
|
46
|
+
|
|
47
|
+
return DiscoveryResult(
|
|
48
|
+
complete=True,
|
|
49
|
+
errors=(f"No discovery protocol registered for source kind: {source.kind}",),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def discover_all(
|
|
53
|
+
self,
|
|
54
|
+
source: Source,
|
|
55
|
+
) -> DiscoveryResult:
|
|
56
|
+
"""Discover all declarations from a source (follows cursors).
|
|
57
|
+
|
|
58
|
+
Repeatedly calls discover() until complete, aggregating all
|
|
59
|
+
declarations into a single result.
|
|
60
|
+
"""
|
|
61
|
+
all_declarations: list[Declaration] = []
|
|
62
|
+
all_errors: list[str] = []
|
|
63
|
+
cursor: Cursor | None = None
|
|
64
|
+
|
|
65
|
+
while True:
|
|
66
|
+
result = self.discover(source, cursor)
|
|
67
|
+
all_declarations.extend(result.declarations)
|
|
68
|
+
all_errors.extend(result.errors)
|
|
69
|
+
|
|
70
|
+
if result.complete or result.next_cursor is None:
|
|
71
|
+
break
|
|
72
|
+
cursor = result.next_cursor
|
|
73
|
+
|
|
74
|
+
return DiscoveryResult(
|
|
75
|
+
declarations=tuple(all_declarations),
|
|
76
|
+
complete=True,
|
|
77
|
+
errors=tuple(all_errors),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def protocol_count(self) -> int:
|
|
82
|
+
"""Number of registered protocols."""
|
|
83
|
+
return len(self._protocols)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Discovery protocol — abstract interface for source adapters.
|
|
2
|
+
|
|
3
|
+
Consumers implement DiscoveryProtocol to teach the engine how to
|
|
4
|
+
find declarations in a specific source kind. The engine never
|
|
5
|
+
does I/O directly — all I/O happens inside protocol implementations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from katalyst_engine.discovery.declaration import Cursor, Declaration
|
|
14
|
+
from katalyst_engine.source.source import Source
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DiscoveryProtocol(ABC):
|
|
18
|
+
"""Abstract base for source discovery implementations.
|
|
19
|
+
|
|
20
|
+
Each source kind (filesystem, database, API) has its own
|
|
21
|
+
DiscoveryProtocol that knows how to enumerate declarations.
|
|
22
|
+
|
|
23
|
+
Implementations must be stateless between calls — all state
|
|
24
|
+
is carried in Cursor objects.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def discover(
|
|
29
|
+
self,
|
|
30
|
+
source: Source,
|
|
31
|
+
cursor: Cursor | None = None,
|
|
32
|
+
) -> DiscoveryResult:
|
|
33
|
+
"""Discover declarations from a source.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
source: The source to discover from.
|
|
37
|
+
cursor: Resume from this position. None means start from beginning.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A DiscoveryResult with declarations and an optional next cursor.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def supports(self, source: Source) -> bool:
|
|
45
|
+
"""Check if this protocol can handle the given source."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class DiscoveryResult:
|
|
50
|
+
"""Result of a discovery pass.
|
|
51
|
+
|
|
52
|
+
Contains the declarations found and an optional cursor for
|
|
53
|
+
resuming discovery (for paginated/incremental sources).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
declarations: tuple[Declaration, ...] = ()
|
|
57
|
+
next_cursor: Cursor | None = None
|
|
58
|
+
complete: bool = True
|
|
59
|
+
errors: tuple[str, ...] = ()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def count(self) -> int:
|
|
63
|
+
"""Number of declarations found."""
|
|
64
|
+
return len(self.declarations)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def has_more(self) -> bool:
|
|
68
|
+
"""True if there are more declarations to discover."""
|
|
69
|
+
return not self.complete and self.next_cursor is not None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""In-process publish/subscribe event bus.
|
|
2
|
+
|
|
3
|
+
Simple, synchronous event dispatch. Every handler sees every event
|
|
4
|
+
(no type-based filtering at the bus level — handlers do their own
|
|
5
|
+
filtering). One failing handler cannot break others.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from katalyst_engine.events.event import Event, EventType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventBus:
|
|
17
|
+
"""Synchronous pub/sub event bus.
|
|
18
|
+
|
|
19
|
+
Handlers are called in subscription order. Exceptions in handlers
|
|
20
|
+
are caught and collected — they never propagate to the publisher.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
bus = EventBus()
|
|
24
|
+
unsub = bus.subscribe(my_handler)
|
|
25
|
+
bus.publish(Event(type=EventType.NODE_CREATED, properties={"name": "app"}))
|
|
26
|
+
unsub() # stop receiving events
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._handlers: list[Callable[[Event], Any]] = []
|
|
31
|
+
self._type_handlers: dict[EventType, list[Callable[[Event], Any]]] = {}
|
|
32
|
+
self._errors: list[tuple[Event, Exception]] = []
|
|
33
|
+
|
|
34
|
+
def subscribe(
|
|
35
|
+
self,
|
|
36
|
+
handler: Callable[[Event], Any],
|
|
37
|
+
event_type: EventType | None = None,
|
|
38
|
+
) -> Callable[[], None]:
|
|
39
|
+
"""Register a handler. Returns an unsubscribe callable.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
handler: Callable that receives an Event. Return value is ignored.
|
|
43
|
+
event_type: If provided, the handler only receives events of this type.
|
|
44
|
+
If None, the handler receives ALL events.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A zero-argument callable that removes this handler.
|
|
48
|
+
"""
|
|
49
|
+
if event_type is not None:
|
|
50
|
+
if event_type not in self._type_handlers:
|
|
51
|
+
self._type_handlers[event_type] = []
|
|
52
|
+
self._type_handlers[event_type].append(handler)
|
|
53
|
+
|
|
54
|
+
def _unsub_typed() -> None:
|
|
55
|
+
handlers = self._type_handlers.get(event_type) # type: ignore[arg-type]
|
|
56
|
+
if handlers and handler in handlers:
|
|
57
|
+
handlers.remove(handler)
|
|
58
|
+
|
|
59
|
+
return _unsub_typed
|
|
60
|
+
|
|
61
|
+
self._handlers.append(handler)
|
|
62
|
+
|
|
63
|
+
def _unsub() -> None:
|
|
64
|
+
if handler in self._handlers:
|
|
65
|
+
self._handlers.remove(handler)
|
|
66
|
+
|
|
67
|
+
return _unsub
|
|
68
|
+
|
|
69
|
+
def publish(self, event: Event) -> None:
|
|
70
|
+
"""Dispatch an event to all matching handlers.
|
|
71
|
+
|
|
72
|
+
Global handlers (subscribed without event_type) are called first,
|
|
73
|
+
then type-specific handlers. Exceptions are caught and stored in
|
|
74
|
+
the errors list.
|
|
75
|
+
"""
|
|
76
|
+
for handler in list(self._handlers):
|
|
77
|
+
try:
|
|
78
|
+
handler(event)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
self._errors.append((event, exc))
|
|
81
|
+
|
|
82
|
+
type_handlers = self._type_handlers.get(event.type, [])
|
|
83
|
+
for handler in list(type_handlers):
|
|
84
|
+
try:
|
|
85
|
+
handler(event)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
self._errors.append((event, exc))
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def errors(self) -> list[tuple[Event, Exception]]:
|
|
91
|
+
"""Events that caused handler exceptions, in order."""
|
|
92
|
+
return list(self._errors)
|
|
93
|
+
|
|
94
|
+
def clear_errors(self) -> None:
|
|
95
|
+
"""Clear the error log."""
|
|
96
|
+
self._errors.clear()
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def handler_count(self) -> int:
|
|
100
|
+
"""Total number of registered handlers (global + typed)."""
|
|
101
|
+
typed_count = sum(len(h) for h in self._type_handlers.values())
|
|
102
|
+
return len(self._handlers) + typed_count
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Event types and event data model.
|
|
2
|
+
|
|
3
|
+
Defines the vocabulary of events the engine can emit. Events are
|
|
4
|
+
simple immutable data carriers — the bus handles dispatch.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventType(str, Enum):
|
|
16
|
+
"""Categories of events the engine can emit.
|
|
17
|
+
|
|
18
|
+
Organized by subsystem. Consumers can subscribe to specific
|
|
19
|
+
types or use pattern matching on the string value.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Lifecycle
|
|
23
|
+
ENGINE_STARTED = "engine.started"
|
|
24
|
+
ENGINE_STOPPED = "engine.stopped"
|
|
25
|
+
|
|
26
|
+
# Source management
|
|
27
|
+
SOURCE_REGISTERED = "source.registered"
|
|
28
|
+
SOURCE_REMOVED = "source.removed"
|
|
29
|
+
SOURCE_SYNC_STARTED = "source.sync.started"
|
|
30
|
+
SOURCE_SYNC_COMPLETED = "source.sync.completed"
|
|
31
|
+
SOURCE_SYNC_FAILED = "source.sync.failed"
|
|
32
|
+
|
|
33
|
+
# Discovery
|
|
34
|
+
DISCOVERY_STARTED = "discovery.started"
|
|
35
|
+
DISCOVERY_COMPLETED = "discovery.completed"
|
|
36
|
+
DECLARATION_FOUND = "discovery.declaration.found"
|
|
37
|
+
|
|
38
|
+
# Schema
|
|
39
|
+
SCHEMA_REGISTERED = "schema.registered"
|
|
40
|
+
SCHEMA_UPDATED = "schema.updated"
|
|
41
|
+
SCHEMA_DEPRECATED = "schema.deprecated"
|
|
42
|
+
|
|
43
|
+
# Model
|
|
44
|
+
NODE_CREATED = "model.node.created"
|
|
45
|
+
NODE_UPDATED = "model.node.updated"
|
|
46
|
+
NODE_DELETED = "model.node.deleted"
|
|
47
|
+
RELATION_CREATED = "model.relation.created"
|
|
48
|
+
RELATION_DELETED = "model.relation.deleted"
|
|
49
|
+
|
|
50
|
+
# Resolution
|
|
51
|
+
CONFLICT_DETECTED = "resolution.conflict.detected"
|
|
52
|
+
CONFLICT_RESOLVED = "resolution.conflict.resolved"
|
|
53
|
+
|
|
54
|
+
# Snapshot
|
|
55
|
+
SNAPSHOT_CREATED = "snapshot.created"
|
|
56
|
+
SNAPSHOT_RESTORED = "snapshot.restored"
|
|
57
|
+
|
|
58
|
+
# Extension
|
|
59
|
+
EXTENSION_REGISTERED = "extension.registered"
|
|
60
|
+
EXTENSION_TRIGGERED = "extension.triggered"
|
|
61
|
+
|
|
62
|
+
# Validation
|
|
63
|
+
VALIDATION_PASSED = "validation.passed"
|
|
64
|
+
VALIDATION_FAILED = "validation.failed"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Event(BaseModel, frozen=True):
|
|
68
|
+
"""An immutable event emitted by the engine.
|
|
69
|
+
|
|
70
|
+
Events carry a type discriminator and a free-form properties
|
|
71
|
+
dict. The engine never inspects properties — consumers use
|
|
72
|
+
them to carry context.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
type: EventType
|
|
76
|
+
"""What happened."""
|
|
77
|
+
|
|
78
|
+
properties: dict[str, Any] = {}
|
|
79
|
+
"""Context-specific data about the event."""
|
|
80
|
+
|
|
81
|
+
source: str = ""
|
|
82
|
+
"""Optional identifier of the subsystem that emitted this event."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Extensions — capabilities, providers, effectors, triggers, and discovery.
|
|
2
|
+
|
|
3
|
+
.. note:: WIRING IN PROGRESS: Taxonomy's plugins/ package is a near-1:1 duplicate.
|
|
4
|
+
PluginRegistry should compose ExtensionRegistry. PluginManifest maps to
|
|
5
|
+
Extension. Trigger/Effector would formalize ad-hoc event handling.
|
|
6
|
+
See ARCHITECTURE_CLEANUP.md Part 2.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from katalyst_engine.extensions.capability import Capability, Extension
|
|
10
|
+
from katalyst_engine.extensions.discovery import (
|
|
11
|
+
DiscoveredExtension,
|
|
12
|
+
ExtensionDiscovery,
|
|
13
|
+
ExtensionSourceKind,
|
|
14
|
+
)
|
|
15
|
+
from katalyst_engine.extensions.effector import Effector, EffectResult
|
|
16
|
+
from katalyst_engine.extensions.provider import Provider
|
|
17
|
+
from katalyst_engine.extensions.registry import ExtensionRegistry
|
|
18
|
+
from katalyst_engine.extensions.trigger import Trigger, TriggerContext
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Capability",
|
|
22
|
+
"DiscoveredExtension",
|
|
23
|
+
"EffectResult",
|
|
24
|
+
"Effector",
|
|
25
|
+
"Extension",
|
|
26
|
+
"ExtensionDiscovery",
|
|
27
|
+
"ExtensionRegistry",
|
|
28
|
+
"ExtensionSourceKind",
|
|
29
|
+
"Provider",
|
|
30
|
+
"Trigger",
|
|
31
|
+
"TriggerContext",
|
|
32
|
+
]
|