mcp-hangar 0.2.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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Kubernetes Discovery Source.
|
|
2
|
+
|
|
3
|
+
Discovers MCP providers from Kubernetes pods/services using annotations.
|
|
4
|
+
Supports both in-cluster and out-of-cluster configuration.
|
|
5
|
+
|
|
6
|
+
Annotation Prefix: mcp.hangar.io/*
|
|
7
|
+
|
|
8
|
+
Example Pod Annotations:
|
|
9
|
+
mcp.hangar.io/enabled: "true"
|
|
10
|
+
mcp.hangar.io/name: "my-provider"
|
|
11
|
+
mcp.hangar.io/mode: "http"
|
|
12
|
+
mcp.hangar.io/port: "8080"
|
|
13
|
+
mcp.hangar.io/group: "data-team"
|
|
14
|
+
mcp.hangar.io/health-path: "/health"
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
from mcp_hangar.domain.discovery.discovered_provider import DiscoveredProvider
|
|
20
|
+
from mcp_hangar.domain.discovery.discovery_source import DiscoveryMode, DiscoverySource
|
|
21
|
+
|
|
22
|
+
from ...logging_config import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
# Optional Kubernetes dependency
|
|
27
|
+
try:
|
|
28
|
+
from kubernetes import client, config
|
|
29
|
+
from kubernetes.client.rest import ApiException
|
|
30
|
+
|
|
31
|
+
KUBERNETES_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
KUBERNETES_AVAILABLE = False
|
|
34
|
+
ApiException = Exception # Fallback for type hints
|
|
35
|
+
client = None
|
|
36
|
+
config = None
|
|
37
|
+
logger.debug("kubernetes package not installed, KubernetesDiscoverySource unavailable")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class KubernetesDiscoverySource(DiscoverySource):
|
|
41
|
+
"""Discover MCP providers from Kubernetes pods/services.
|
|
42
|
+
|
|
43
|
+
Uses pod annotations to discover and configure MCP providers.
|
|
44
|
+
Supports namespace filtering and label selectors.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
ANNOTATION_PREFIX: Prefix for all MCP annotations
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
ANNOTATION_PREFIX = "mcp.hangar.io/"
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
mode: DiscoveryMode = DiscoveryMode.AUTHORITATIVE,
|
|
55
|
+
namespaces: Optional[List[str]] = None,
|
|
56
|
+
label_selector: Optional[str] = None,
|
|
57
|
+
in_cluster: bool = True,
|
|
58
|
+
kubeconfig_path: Optional[str] = None,
|
|
59
|
+
default_ttl: int = 90,
|
|
60
|
+
):
|
|
61
|
+
"""Initialize Kubernetes discovery source.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
mode: Discovery mode (default: authoritative for K8s)
|
|
65
|
+
namespaces: List of namespaces to watch (None = all)
|
|
66
|
+
label_selector: Kubernetes label selector
|
|
67
|
+
in_cluster: Whether running inside cluster
|
|
68
|
+
kubeconfig_path: Path to kubeconfig (for out-of-cluster)
|
|
69
|
+
default_ttl: Default TTL for discovered providers
|
|
70
|
+
"""
|
|
71
|
+
super().__init__(mode)
|
|
72
|
+
|
|
73
|
+
if not KUBERNETES_AVAILABLE:
|
|
74
|
+
raise ImportError(
|
|
75
|
+
"kubernetes package is required for KubernetesDiscoverySource. Install with: pip install kubernetes"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
self.namespaces = namespaces or []
|
|
79
|
+
self.label_selector = label_selector
|
|
80
|
+
self.in_cluster = in_cluster
|
|
81
|
+
self.kubeconfig_path = kubeconfig_path
|
|
82
|
+
self.default_ttl = default_ttl
|
|
83
|
+
|
|
84
|
+
self._v1: Optional[client.CoreV1Api] = None
|
|
85
|
+
self._initialized = False
|
|
86
|
+
|
|
87
|
+
def _ensure_initialized(self) -> None:
|
|
88
|
+
"""Ensure Kubernetes client is initialized."""
|
|
89
|
+
if self._initialized:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
if self.in_cluster:
|
|
94
|
+
config.load_incluster_config()
|
|
95
|
+
else:
|
|
96
|
+
config.load_kube_config(config_file=self.kubeconfig_path)
|
|
97
|
+
|
|
98
|
+
self._v1 = client.CoreV1Api()
|
|
99
|
+
self._initialized = True
|
|
100
|
+
logger.info(
|
|
101
|
+
f"Kubernetes discovery initialized "
|
|
102
|
+
f"(in_cluster={self.in_cluster}, namespaces={self.namespaces or 'all'})"
|
|
103
|
+
)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.error(f"Failed to initialize Kubernetes client: {e}")
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def source_type(self) -> str:
|
|
110
|
+
return "kubernetes"
|
|
111
|
+
|
|
112
|
+
async def discover(self) -> List[DiscoveredProvider]:
|
|
113
|
+
"""Discover providers from pod annotations.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of discovered providers
|
|
117
|
+
"""
|
|
118
|
+
self._ensure_initialized()
|
|
119
|
+
providers = []
|
|
120
|
+
|
|
121
|
+
namespaces = self.namespaces or await self._get_all_namespaces()
|
|
122
|
+
|
|
123
|
+
for namespace in namespaces:
|
|
124
|
+
try:
|
|
125
|
+
pods = self._v1.list_namespaced_pod(namespace=namespace, label_selector=self.label_selector)
|
|
126
|
+
|
|
127
|
+
for pod in pods.items:
|
|
128
|
+
provider = self._parse_pod(pod, namespace)
|
|
129
|
+
if provider:
|
|
130
|
+
providers.append(provider)
|
|
131
|
+
await self.on_provider_discovered(provider)
|
|
132
|
+
|
|
133
|
+
except ApiException as e:
|
|
134
|
+
logger.warning(f"Failed to list pods in {namespace}: {e.reason}")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.error(f"Error discovering in namespace {namespace}: {e}")
|
|
137
|
+
|
|
138
|
+
logger.debug(f"Kubernetes discovery found {len(providers)} providers")
|
|
139
|
+
return providers
|
|
140
|
+
|
|
141
|
+
def _parse_pod(self, pod, namespace: str) -> Optional[DiscoveredProvider]:
|
|
142
|
+
"""Parse pod annotations into DiscoveredProvider.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
pod: Kubernetes pod object
|
|
146
|
+
namespace: Pod namespace
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
DiscoveredProvider or None if not MCP-enabled
|
|
150
|
+
"""
|
|
151
|
+
annotations = pod.metadata.annotations or {}
|
|
152
|
+
|
|
153
|
+
# Check if MCP discovery is enabled
|
|
154
|
+
enabled = annotations.get(f"{self.ANNOTATION_PREFIX}enabled", "false")
|
|
155
|
+
if enabled.lower() != "true":
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# Extract provider config
|
|
159
|
+
name = annotations.get(f"{self.ANNOTATION_PREFIX}name", pod.metadata.name)
|
|
160
|
+
mode = annotations.get(f"{self.ANNOTATION_PREFIX}mode", "http")
|
|
161
|
+
port = annotations.get(f"{self.ANNOTATION_PREFIX}port", "8080")
|
|
162
|
+
group = annotations.get(f"{self.ANNOTATION_PREFIX}group")
|
|
163
|
+
health_path = annotations.get(f"{self.ANNOTATION_PREFIX}health-path", "/health")
|
|
164
|
+
ttl = int(annotations.get(f"{self.ANNOTATION_PREFIX}ttl", str(self.default_ttl)))
|
|
165
|
+
|
|
166
|
+
# Get pod IP
|
|
167
|
+
pod_ip = pod.status.pod_ip if pod.status else None
|
|
168
|
+
if not pod_ip:
|
|
169
|
+
logger.debug(f"Pod {pod.metadata.name} has no IP, skipping")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
# Check pod phase
|
|
173
|
+
phase = pod.status.phase if pod.status else "Unknown"
|
|
174
|
+
if phase != "Running":
|
|
175
|
+
logger.debug(f"Pod {pod.metadata.name} not running (phase={phase}), skipping")
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
# Build connection info
|
|
179
|
+
connection_info = {
|
|
180
|
+
"host": pod_ip,
|
|
181
|
+
"port": int(port),
|
|
182
|
+
"health_path": health_path,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Handle subprocess mode
|
|
186
|
+
if mode == "subprocess" or mode == "stdio":
|
|
187
|
+
command = annotations.get(f"{self.ANNOTATION_PREFIX}command")
|
|
188
|
+
if command:
|
|
189
|
+
connection_info["command"] = command.split()
|
|
190
|
+
|
|
191
|
+
metadata = {
|
|
192
|
+
"namespace": namespace,
|
|
193
|
+
"pod_name": pod.metadata.name,
|
|
194
|
+
"pod_uid": pod.metadata.uid,
|
|
195
|
+
"group": group,
|
|
196
|
+
"labels": pod.metadata.labels or {},
|
|
197
|
+
"annotations": {k: v for k, v in annotations.items() if k.startswith(self.ANNOTATION_PREFIX)},
|
|
198
|
+
"node_name": pod.spec.node_name if pod.spec else None,
|
|
199
|
+
"phase": phase,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return DiscoveredProvider.create(
|
|
203
|
+
name=name,
|
|
204
|
+
source_type=self.source_type,
|
|
205
|
+
mode=mode,
|
|
206
|
+
connection_info=connection_info,
|
|
207
|
+
metadata=metadata,
|
|
208
|
+
ttl_seconds=ttl,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def health_check(self) -> bool:
|
|
212
|
+
"""Check Kubernetes API availability.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if API is accessible
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
self._ensure_initialized()
|
|
219
|
+
self._v1.get_api_resources()
|
|
220
|
+
return True
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.warning(f"Kubernetes health check failed: {e}")
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
async def _get_all_namespaces(self) -> List[str]:
|
|
226
|
+
"""Get all namespace names.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
List of namespace names
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
namespaces = self._v1.list_namespace()
|
|
233
|
+
return [ns.metadata.name for ns in namespaces.items]
|
|
234
|
+
except ApiException as e:
|
|
235
|
+
logger.error(f"Failed to list namespaces: {e.reason}")
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
async def start(self) -> None:
|
|
239
|
+
"""Start the Kubernetes discovery source."""
|
|
240
|
+
self._ensure_initialized()
|
|
241
|
+
logger.info("Kubernetes discovery source started")
|
|
242
|
+
|
|
243
|
+
async def stop(self) -> None:
|
|
244
|
+
"""Stop the Kubernetes discovery source."""
|
|
245
|
+
self._initialized = False
|
|
246
|
+
self._v1 = None
|
|
247
|
+
logger.info("Kubernetes discovery source stopped")
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Event bus for publish/subscribe pattern.
|
|
2
|
+
|
|
3
|
+
The event bus allows decoupled communication between components via domain events.
|
|
4
|
+
Supports optional event persistence via IEventStore.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
from typing import Callable, Dict, List, Type
|
|
9
|
+
|
|
10
|
+
from mcp_hangar.domain.contracts.event_store import IEventStore, NullEventStore
|
|
11
|
+
from mcp_hangar.domain.events import DomainEvent
|
|
12
|
+
from mcp_hangar.logging_config import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventHandler:
|
|
18
|
+
"""Base class for event handlers."""
|
|
19
|
+
|
|
20
|
+
def handle(self, event: DomainEvent) -> None:
|
|
21
|
+
"""Handle a domain event."""
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EventBus:
|
|
26
|
+
"""
|
|
27
|
+
Thread-safe event bus for publishing and subscribing to domain events.
|
|
28
|
+
|
|
29
|
+
Supports multiple subscribers per event type.
|
|
30
|
+
Handlers are called synchronously in order of subscription.
|
|
31
|
+
Optionally persists events via IEventStore before publishing.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, event_store: IEventStore | None = None):
|
|
35
|
+
"""Initialize event bus.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
event_store: Optional event store for persistence.
|
|
39
|
+
If None, events are not persisted.
|
|
40
|
+
"""
|
|
41
|
+
self._handlers: Dict[Type[DomainEvent], List[Callable[[DomainEvent], None]]] = {}
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
self._error_handlers: List[Callable[[Exception, DomainEvent], None]] = []
|
|
44
|
+
self._event_store = event_store or NullEventStore()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def event_store(self) -> IEventStore:
|
|
48
|
+
"""Get the event store instance."""
|
|
49
|
+
return self._event_store
|
|
50
|
+
|
|
51
|
+
def set_event_store(self, event_store: IEventStore) -> None:
|
|
52
|
+
"""Set the event store (for late binding during bootstrap).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
event_store: Event store implementation.
|
|
56
|
+
"""
|
|
57
|
+
self._event_store = event_store
|
|
58
|
+
logger.info("event_store_configured", store_type=type(event_store).__name__)
|
|
59
|
+
|
|
60
|
+
def subscribe(self, event_type: Type[DomainEvent], handler: Callable[[DomainEvent], None]) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Subscribe to a specific event type.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
event_type: The type of event to subscribe to
|
|
66
|
+
handler: Callable that takes the event as parameter
|
|
67
|
+
"""
|
|
68
|
+
with self._lock:
|
|
69
|
+
if event_type not in self._handlers:
|
|
70
|
+
self._handlers[event_type] = []
|
|
71
|
+
self._handlers[event_type].append(handler)
|
|
72
|
+
|
|
73
|
+
logger.debug(f"Subscribed handler to {event_type.__name__}")
|
|
74
|
+
|
|
75
|
+
def subscribe_to_all(self, handler: Callable[[DomainEvent], None]) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Subscribe to all event types.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
handler: Callable that takes any event as parameter
|
|
81
|
+
"""
|
|
82
|
+
with self._lock:
|
|
83
|
+
if DomainEvent not in self._handlers:
|
|
84
|
+
self._handlers[DomainEvent] = []
|
|
85
|
+
self._handlers[DomainEvent].append(handler)
|
|
86
|
+
|
|
87
|
+
logger.debug("Subscribed handler to all events")
|
|
88
|
+
|
|
89
|
+
def unsubscribe(self, event_type: Type[DomainEvent], handler: Callable[[DomainEvent], None]) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Unsubscribe a handler from an event type.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
event_type: The type of event
|
|
95
|
+
handler: The handler to remove
|
|
96
|
+
"""
|
|
97
|
+
with self._lock:
|
|
98
|
+
if event_type in self._handlers:
|
|
99
|
+
self._handlers[event_type].remove(handler)
|
|
100
|
+
|
|
101
|
+
def publish(self, event: DomainEvent) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Publish an event to all subscribed handlers.
|
|
104
|
+
|
|
105
|
+
Handlers are called synchronously in subscription order.
|
|
106
|
+
If a handler fails, the exception is logged and remaining handlers
|
|
107
|
+
are still called.
|
|
108
|
+
|
|
109
|
+
Note: This method does NOT persist events. Use publish_to_stream()
|
|
110
|
+
for event persistence.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
event: The domain event to publish
|
|
114
|
+
"""
|
|
115
|
+
with self._lock:
|
|
116
|
+
# Get handlers for this specific event type
|
|
117
|
+
specific_handlers = self._handlers.get(type(event), [])
|
|
118
|
+
# Get handlers subscribed to all events
|
|
119
|
+
all_handlers = self._handlers.get(DomainEvent, [])
|
|
120
|
+
handlers = specific_handlers + all_handlers
|
|
121
|
+
|
|
122
|
+
logger.debug(
|
|
123
|
+
"event_publishing",
|
|
124
|
+
event_type=event.__class__.__name__,
|
|
125
|
+
handlers_count=len(handlers),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Call handlers outside the lock
|
|
129
|
+
for handler in handlers:
|
|
130
|
+
try:
|
|
131
|
+
handler(event)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.exception(
|
|
134
|
+
"event_handler_error",
|
|
135
|
+
event_type=event.__class__.__name__,
|
|
136
|
+
error=str(e),
|
|
137
|
+
)
|
|
138
|
+
# Call error handlers
|
|
139
|
+
for error_handler in self._error_handlers:
|
|
140
|
+
try:
|
|
141
|
+
error_handler(e, event)
|
|
142
|
+
except Exception as eh:
|
|
143
|
+
logger.exception(
|
|
144
|
+
"event_error_handler_failed",
|
|
145
|
+
event_type=event.__class__.__name__,
|
|
146
|
+
error=str(eh),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def publish_to_stream(
|
|
150
|
+
self,
|
|
151
|
+
stream_id: str,
|
|
152
|
+
events: list[DomainEvent],
|
|
153
|
+
expected_version: int = -1,
|
|
154
|
+
) -> int:
|
|
155
|
+
"""
|
|
156
|
+
Persist events to a stream and then publish to handlers.
|
|
157
|
+
|
|
158
|
+
This method provides full Event Sourcing support:
|
|
159
|
+
1. Persists events to the event store (with optimistic concurrency)
|
|
160
|
+
2. Publishes events to subscribed handlers
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
stream_id: Stream identifier (e.g., "provider:math")
|
|
164
|
+
events: List of events to persist and publish
|
|
165
|
+
expected_version: Expected stream version for concurrency check.
|
|
166
|
+
Use -1 for new streams.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
New stream version after append.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ConcurrencyError: If version mismatch in event store.
|
|
173
|
+
"""
|
|
174
|
+
if not events:
|
|
175
|
+
return expected_version
|
|
176
|
+
|
|
177
|
+
# Persist first (fail fast if concurrency error)
|
|
178
|
+
new_version = self._event_store.append(stream_id, events, expected_version)
|
|
179
|
+
|
|
180
|
+
logger.debug(
|
|
181
|
+
"events_persisted",
|
|
182
|
+
stream_id=stream_id,
|
|
183
|
+
events_count=len(events),
|
|
184
|
+
new_version=new_version,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Then publish to handlers
|
|
188
|
+
for event in events:
|
|
189
|
+
self.publish(event)
|
|
190
|
+
|
|
191
|
+
return new_version
|
|
192
|
+
|
|
193
|
+
def publish_aggregate_events(
|
|
194
|
+
self,
|
|
195
|
+
aggregate_type: str,
|
|
196
|
+
aggregate_id: str,
|
|
197
|
+
events: list[DomainEvent],
|
|
198
|
+
expected_version: int = -1,
|
|
199
|
+
) -> int:
|
|
200
|
+
"""
|
|
201
|
+
Convenience method for publishing aggregate events.
|
|
202
|
+
|
|
203
|
+
Constructs stream_id from aggregate type and ID.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
aggregate_type: Type of aggregate (e.g., "provider", "provider_group")
|
|
207
|
+
aggregate_id: Unique identifier of the aggregate
|
|
208
|
+
events: Events collected from aggregate
|
|
209
|
+
expected_version: Expected version for concurrency
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
New stream version.
|
|
213
|
+
"""
|
|
214
|
+
stream_id = f"{aggregate_type}:{aggregate_id}"
|
|
215
|
+
return self.publish_to_stream(stream_id, events, expected_version)
|
|
216
|
+
|
|
217
|
+
def on_error(self, handler: Callable[[Exception, DomainEvent], None]) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Register a handler for errors that occur during event handling.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
handler: Callable that takes (exception, event)
|
|
223
|
+
"""
|
|
224
|
+
self._error_handlers.append(handler)
|
|
225
|
+
|
|
226
|
+
def clear(self) -> None:
|
|
227
|
+
"""Clear all subscriptions (mainly for testing)."""
|
|
228
|
+
with self._lock:
|
|
229
|
+
self._handlers.clear()
|
|
230
|
+
self._error_handlers.clear()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Global event bus instance
|
|
234
|
+
_global_event_bus: EventBus | None = None
|
|
235
|
+
_global_bus_lock = threading.Lock()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_event_bus() -> EventBus:
|
|
239
|
+
"""
|
|
240
|
+
Get the global event bus instance (singleton pattern).
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
The global EventBus instance
|
|
244
|
+
"""
|
|
245
|
+
global _global_event_bus
|
|
246
|
+
|
|
247
|
+
if _global_event_bus is None:
|
|
248
|
+
with _global_bus_lock:
|
|
249
|
+
if _global_event_bus is None:
|
|
250
|
+
_global_event_bus = EventBus()
|
|
251
|
+
|
|
252
|
+
return _global_event_bus
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def reset_event_bus() -> None:
|
|
256
|
+
"""Reset the global event bus (mainly for testing)."""
|
|
257
|
+
global _global_event_bus
|
|
258
|
+
|
|
259
|
+
with _global_bus_lock:
|
|
260
|
+
_global_event_bus = None
|