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.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. 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