hexdag 0.5.0.dev1__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 (261) hide show
  1. hexdag/__init__.py +116 -0
  2. hexdag/__main__.py +30 -0
  3. hexdag/adapters/executors/__init__.py +5 -0
  4. hexdag/adapters/executors/local_executor.py +316 -0
  5. hexdag/builtin/__init__.py +6 -0
  6. hexdag/builtin/adapters/__init__.py +51 -0
  7. hexdag/builtin/adapters/anthropic/__init__.py +5 -0
  8. hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
  9. hexdag/builtin/adapters/database/__init__.py +6 -0
  10. hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
  11. hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
  12. hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
  13. hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
  14. hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
  15. hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
  16. hexdag/builtin/adapters/local/README.md +59 -0
  17. hexdag/builtin/adapters/local/__init__.py +7 -0
  18. hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
  19. hexdag/builtin/adapters/memory/__init__.py +47 -0
  20. hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
  21. hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
  22. hexdag/builtin/adapters/memory/schemas.py +57 -0
  23. hexdag/builtin/adapters/memory/session_memory.py +178 -0
  24. hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
  25. hexdag/builtin/adapters/memory/state_memory.py +280 -0
  26. hexdag/builtin/adapters/mock/README.md +89 -0
  27. hexdag/builtin/adapters/mock/__init__.py +15 -0
  28. hexdag/builtin/adapters/mock/hexdag.toml +50 -0
  29. hexdag/builtin/adapters/mock/mock_database.py +225 -0
  30. hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
  31. hexdag/builtin/adapters/mock/mock_llm.py +177 -0
  32. hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
  33. hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
  34. hexdag/builtin/adapters/openai/__init__.py +5 -0
  35. hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
  36. hexdag/builtin/adapters/secret/__init__.py +7 -0
  37. hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
  38. hexdag/builtin/adapters/unified_tool_router.py +280 -0
  39. hexdag/builtin/macros/__init__.py +17 -0
  40. hexdag/builtin/macros/conversation_agent.py +390 -0
  41. hexdag/builtin/macros/llm_macro.py +151 -0
  42. hexdag/builtin/macros/reasoning_agent.py +423 -0
  43. hexdag/builtin/macros/tool_macro.py +380 -0
  44. hexdag/builtin/nodes/__init__.py +38 -0
  45. hexdag/builtin/nodes/_discovery.py +123 -0
  46. hexdag/builtin/nodes/agent_node.py +696 -0
  47. hexdag/builtin/nodes/base_node_factory.py +242 -0
  48. hexdag/builtin/nodes/composite_node.py +926 -0
  49. hexdag/builtin/nodes/data_node.py +201 -0
  50. hexdag/builtin/nodes/expression_node.py +487 -0
  51. hexdag/builtin/nodes/function_node.py +454 -0
  52. hexdag/builtin/nodes/llm_node.py +491 -0
  53. hexdag/builtin/nodes/loop_node.py +920 -0
  54. hexdag/builtin/nodes/mapped_input.py +518 -0
  55. hexdag/builtin/nodes/port_call_node.py +269 -0
  56. hexdag/builtin/nodes/tool_call_node.py +195 -0
  57. hexdag/builtin/nodes/tool_utils.py +390 -0
  58. hexdag/builtin/prompts/__init__.py +68 -0
  59. hexdag/builtin/prompts/base.py +422 -0
  60. hexdag/builtin/prompts/chat_prompts.py +303 -0
  61. hexdag/builtin/prompts/error_correction_prompts.py +320 -0
  62. hexdag/builtin/prompts/tool_prompts.py +160 -0
  63. hexdag/builtin/tools/builtin_tools.py +84 -0
  64. hexdag/builtin/tools/database_tools.py +164 -0
  65. hexdag/cli/__init__.py +17 -0
  66. hexdag/cli/__main__.py +7 -0
  67. hexdag/cli/commands/__init__.py +27 -0
  68. hexdag/cli/commands/build_cmd.py +812 -0
  69. hexdag/cli/commands/create_cmd.py +208 -0
  70. hexdag/cli/commands/docs_cmd.py +293 -0
  71. hexdag/cli/commands/generate_types_cmd.py +252 -0
  72. hexdag/cli/commands/init_cmd.py +188 -0
  73. hexdag/cli/commands/pipeline_cmd.py +494 -0
  74. hexdag/cli/commands/plugin_dev_cmd.py +529 -0
  75. hexdag/cli/commands/plugins_cmd.py +441 -0
  76. hexdag/cli/commands/studio_cmd.py +101 -0
  77. hexdag/cli/commands/validate_cmd.py +221 -0
  78. hexdag/cli/main.py +84 -0
  79. hexdag/core/__init__.py +83 -0
  80. hexdag/core/config/__init__.py +20 -0
  81. hexdag/core/config/loader.py +479 -0
  82. hexdag/core/config/models.py +150 -0
  83. hexdag/core/configurable.py +294 -0
  84. hexdag/core/context/__init__.py +37 -0
  85. hexdag/core/context/execution_context.py +378 -0
  86. hexdag/core/docs/__init__.py +26 -0
  87. hexdag/core/docs/extractors.py +678 -0
  88. hexdag/core/docs/generators.py +890 -0
  89. hexdag/core/docs/models.py +120 -0
  90. hexdag/core/domain/__init__.py +10 -0
  91. hexdag/core/domain/dag.py +1225 -0
  92. hexdag/core/exceptions.py +234 -0
  93. hexdag/core/expression_parser.py +569 -0
  94. hexdag/core/logging.py +449 -0
  95. hexdag/core/models/__init__.py +17 -0
  96. hexdag/core/models/base.py +138 -0
  97. hexdag/core/orchestration/__init__.py +46 -0
  98. hexdag/core/orchestration/body_executor.py +481 -0
  99. hexdag/core/orchestration/components/__init__.py +97 -0
  100. hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
  101. hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
  102. hexdag/core/orchestration/components/execution_coordinator.py +360 -0
  103. hexdag/core/orchestration/components/health_check_manager.py +176 -0
  104. hexdag/core/orchestration/components/input_mapper.py +143 -0
  105. hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
  106. hexdag/core/orchestration/components/node_executor.py +377 -0
  107. hexdag/core/orchestration/components/secret_manager.py +202 -0
  108. hexdag/core/orchestration/components/wave_executor.py +158 -0
  109. hexdag/core/orchestration/constants.py +17 -0
  110. hexdag/core/orchestration/events/README.md +312 -0
  111. hexdag/core/orchestration/events/__init__.py +104 -0
  112. hexdag/core/orchestration/events/batching.py +330 -0
  113. hexdag/core/orchestration/events/decorators.py +139 -0
  114. hexdag/core/orchestration/events/events.py +573 -0
  115. hexdag/core/orchestration/events/observers/__init__.py +30 -0
  116. hexdag/core/orchestration/events/observers/core_observers.py +690 -0
  117. hexdag/core/orchestration/events/observers/models.py +111 -0
  118. hexdag/core/orchestration/events/taxonomy.py +269 -0
  119. hexdag/core/orchestration/hook_context.py +237 -0
  120. hexdag/core/orchestration/hooks.py +437 -0
  121. hexdag/core/orchestration/models.py +418 -0
  122. hexdag/core/orchestration/orchestrator.py +910 -0
  123. hexdag/core/orchestration/orchestrator_factory.py +275 -0
  124. hexdag/core/orchestration/port_wrappers.py +327 -0
  125. hexdag/core/orchestration/prompt/__init__.py +32 -0
  126. hexdag/core/orchestration/prompt/template.py +332 -0
  127. hexdag/core/pipeline_builder/__init__.py +21 -0
  128. hexdag/core/pipeline_builder/component_instantiator.py +386 -0
  129. hexdag/core/pipeline_builder/include_tag.py +265 -0
  130. hexdag/core/pipeline_builder/pipeline_config.py +133 -0
  131. hexdag/core/pipeline_builder/py_tag.py +223 -0
  132. hexdag/core/pipeline_builder/tag_discovery.py +268 -0
  133. hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
  134. hexdag/core/pipeline_builder/yaml_validator.py +569 -0
  135. hexdag/core/ports/__init__.py +65 -0
  136. hexdag/core/ports/api_call.py +133 -0
  137. hexdag/core/ports/database.py +489 -0
  138. hexdag/core/ports/embedding.py +215 -0
  139. hexdag/core/ports/executor.py +237 -0
  140. hexdag/core/ports/file_storage.py +117 -0
  141. hexdag/core/ports/healthcheck.py +87 -0
  142. hexdag/core/ports/llm.py +551 -0
  143. hexdag/core/ports/memory.py +70 -0
  144. hexdag/core/ports/observer_manager.py +130 -0
  145. hexdag/core/ports/secret.py +145 -0
  146. hexdag/core/ports/tool_router.py +94 -0
  147. hexdag/core/ports_builder.py +623 -0
  148. hexdag/core/protocols.py +273 -0
  149. hexdag/core/resolver.py +304 -0
  150. hexdag/core/schema/__init__.py +9 -0
  151. hexdag/core/schema/generator.py +742 -0
  152. hexdag/core/secrets.py +242 -0
  153. hexdag/core/types.py +413 -0
  154. hexdag/core/utils/async_warnings.py +206 -0
  155. hexdag/core/utils/schema_conversion.py +78 -0
  156. hexdag/core/utils/sql_validation.py +86 -0
  157. hexdag/core/validation/secure_json.py +148 -0
  158. hexdag/core/yaml_macro.py +517 -0
  159. hexdag/mcp_server.py +3120 -0
  160. hexdag/studio/__init__.py +10 -0
  161. hexdag/studio/build_ui.py +92 -0
  162. hexdag/studio/server/__init__.py +1 -0
  163. hexdag/studio/server/main.py +100 -0
  164. hexdag/studio/server/routes/__init__.py +9 -0
  165. hexdag/studio/server/routes/execute.py +208 -0
  166. hexdag/studio/server/routes/export.py +558 -0
  167. hexdag/studio/server/routes/files.py +207 -0
  168. hexdag/studio/server/routes/plugins.py +419 -0
  169. hexdag/studio/server/routes/validate.py +220 -0
  170. hexdag/studio/ui/index.html +13 -0
  171. hexdag/studio/ui/package-lock.json +2992 -0
  172. hexdag/studio/ui/package.json +31 -0
  173. hexdag/studio/ui/postcss.config.js +6 -0
  174. hexdag/studio/ui/public/hexdag.svg +5 -0
  175. hexdag/studio/ui/src/App.tsx +251 -0
  176. hexdag/studio/ui/src/components/Canvas.tsx +408 -0
  177. hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
  178. hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
  179. hexdag/studio/ui/src/components/Header.tsx +181 -0
  180. hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
  181. hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
  182. hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
  183. hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
  184. hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
  185. hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
  186. hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
  187. hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
  188. hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
  189. hexdag/studio/ui/src/components/index.ts +8 -0
  190. hexdag/studio/ui/src/index.css +92 -0
  191. hexdag/studio/ui/src/main.tsx +10 -0
  192. hexdag/studio/ui/src/types/index.ts +123 -0
  193. hexdag/studio/ui/src/vite-env.d.ts +1 -0
  194. hexdag/studio/ui/tailwind.config.js +29 -0
  195. hexdag/studio/ui/tsconfig.json +37 -0
  196. hexdag/studio/ui/tsconfig.node.json +13 -0
  197. hexdag/studio/ui/vite.config.ts +35 -0
  198. hexdag/visualization/__init__.py +69 -0
  199. hexdag/visualization/dag_visualizer.py +1020 -0
  200. hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
  201. hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
  202. hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
  203. hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
  204. hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
  205. hexdag_plugins/.gitignore +43 -0
  206. hexdag_plugins/README.md +73 -0
  207. hexdag_plugins/__init__.py +1 -0
  208. hexdag_plugins/azure/LICENSE +21 -0
  209. hexdag_plugins/azure/README.md +414 -0
  210. hexdag_plugins/azure/__init__.py +21 -0
  211. hexdag_plugins/azure/azure_blob_adapter.py +450 -0
  212. hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
  213. hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
  214. hexdag_plugins/azure/azure_openai_adapter.py +415 -0
  215. hexdag_plugins/azure/pyproject.toml +107 -0
  216. hexdag_plugins/azure/tests/__init__.py +1 -0
  217. hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
  218. hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
  219. hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
  220. hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
  221. hexdag_plugins/hexdag_etl/README.md +168 -0
  222. hexdag_plugins/hexdag_etl/__init__.py +53 -0
  223. hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
  224. hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
  225. hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
  226. hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
  227. hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
  228. hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
  229. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
  230. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
  231. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
  232. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
  233. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
  234. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
  235. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
  236. hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
  237. hexdag_plugins/hexdag_etl/test_transform.py +54 -0
  238. hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
  239. hexdag_plugins/mysql_adapter/LICENSE +21 -0
  240. hexdag_plugins/mysql_adapter/README.md +224 -0
  241. hexdag_plugins/mysql_adapter/__init__.py +6 -0
  242. hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
  243. hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
  244. hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
  245. hexdag_plugins/storage/README.md +184 -0
  246. hexdag_plugins/storage/__init__.py +19 -0
  247. hexdag_plugins/storage/file/__init__.py +5 -0
  248. hexdag_plugins/storage/file/local.py +325 -0
  249. hexdag_plugins/storage/ports/__init__.py +5 -0
  250. hexdag_plugins/storage/ports/vector_store.py +236 -0
  251. hexdag_plugins/storage/sql/__init__.py +7 -0
  252. hexdag_plugins/storage/sql/base.py +187 -0
  253. hexdag_plugins/storage/sql/mysql.py +27 -0
  254. hexdag_plugins/storage/sql/postgresql.py +27 -0
  255. hexdag_plugins/storage/tests/__init__.py +1 -0
  256. hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
  257. hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
  258. hexdag_plugins/storage/vector/__init__.py +7 -0
  259. hexdag_plugins/storage/vector/chromadb.py +223 -0
  260. hexdag_plugins/storage/vector/in_memory.py +285 -0
  261. hexdag_plugins/storage/vector/pgvector.py +502 -0
@@ -0,0 +1,696 @@
1
+ """Local Observer Manager Adapter - Standalone implementation of observer pattern.
2
+
3
+ This adapter provides a complete, standalone implementation of the ObserverManagerPort
4
+ interface with all safety features including weak references, event filtering,
5
+ concurrency control, and fault isolation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import inspect
12
+ import logging
13
+ import uuid
14
+ import weakref
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from typing import TYPE_CHECKING, Any, Protocol, cast
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
19
+
20
+ from hexdag.core.orchestration.events.batching import (
21
+ BatchingConfig,
22
+ BatchingMetrics,
23
+ EventBatchEnvelope,
24
+ EventBatcher,
25
+ )
26
+ from hexdag.core.orchestration.events.decorators import (
27
+ EVENT_METADATA_ATTR,
28
+ EventDecoratorMetadata,
29
+ EventType,
30
+ EventTypesInput,
31
+ normalize_event_types,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Awaitable, Sequence
36
+
37
+ from hexdag.core.orchestration.events.events import Event
38
+ from hexdag.core.ports.observer_manager import (
39
+ AsyncObserverFunc,
40
+ Observer,
41
+ ObserverFunc,
42
+ )
43
+
44
+ LOGGER = logging.getLogger(__name__)
45
+
46
+
47
+ class ErrorHandler(Protocol):
48
+ """Protocol for handling errors in event system."""
49
+
50
+ def handle_error(self, error: Exception, context: dict[str, Any]) -> None:
51
+ """Handle an error that occurred during event processing."""
52
+ ...
53
+
54
+
55
+ class LoggingErrorHandler:
56
+ """Default error handler that logs errors."""
57
+
58
+ def __init__(self, logger: Any | None = None):
59
+ """Initialize with optional logger."""
60
+ self.logger: Any = logger if logger is not None else LOGGER
61
+
62
+ def handle_error(self, error: Exception, context: dict[str, Any]) -> None:
63
+ """Log the error with context."""
64
+ handler_name = context.get("handler_name", "unknown")
65
+ event_type = context.get("event_type", "unknown")
66
+
67
+ if context.get("is_critical", False):
68
+ self.logger.error(
69
+ f"Critical handler {handler_name} failed for {event_type}: {error}", exc_info=True
70
+ )
71
+ else:
72
+ self.logger.warning(f"Handler {handler_name} failed for {event_type}: {error}")
73
+
74
+
75
+ class FunctionObserver:
76
+ """Wrapper to make functions implement the Observer protocol."""
77
+
78
+ def __init__(self, func: ObserverFunc | AsyncObserverFunc, executor: ThreadPoolExecutor):
79
+ self._func = func
80
+ self._executor = executor
81
+ self.__name__ = getattr(func, "__name__", "anonymous_observer")
82
+
83
+ async def handle(self, event: Event) -> None:
84
+ """Handle the event by calling the wrapped function."""
85
+ if asyncio.iscoroutinefunction(self._func):
86
+ await self._func(event)
87
+ else:
88
+ # Run sync function in thread pool to avoid blocking
89
+ loop = asyncio.get_running_loop()
90
+ await loop.run_in_executor(self._executor, self._func, event)
91
+
92
+
93
+ # Default configuration constants
94
+ DEFAULT_MAX_CONCURRENT_OBSERVERS = 10
95
+ DEFAULT_OBSERVER_TIMEOUT = 5.0
96
+ DEFAULT_MAX_SYNC_WORKERS = 4
97
+ DEFAULT_CLEANUP_INTERVAL = 0.1
98
+
99
+
100
+ class ObserverRegistrationConfig(BaseModel):
101
+ """Validated configuration for observer registration."""
102
+
103
+ model_config = ConfigDict(extra="forbid")
104
+
105
+ observer_id: str | None = None
106
+ event_types: set[EventType] | None = None
107
+ timeout: float | None = Field(None, gt=0)
108
+ max_concurrency: int | None = Field(None, ge=1)
109
+ keep_alive: bool = False
110
+
111
+ @field_validator("event_types", mode="before")
112
+ @classmethod
113
+ def validate_event_types(cls, value: EventTypesInput) -> set[EventType] | None:
114
+ return normalize_event_types(value)
115
+
116
+
117
+ class LocalObserverManager:
118
+ """Local standalone implementation of observer manager.
119
+
120
+ This implementation provides:
121
+ - Weak reference support to prevent memory leaks
122
+ - Event type filtering for efficiency
123
+ - Concurrent observer execution with limits
124
+ - Fault isolation - observer failures don't crash the pipeline
125
+ - Timeout handling for slow observers
126
+ - Thread pool for sync observers to avoid blocking
127
+ """
128
+
129
+ @staticmethod
130
+ def _get_observer_metadata(handler: Any) -> EventDecoratorMetadata | None:
131
+ """Return observer metadata if present."""
132
+ metadata = getattr(handler, EVENT_METADATA_ATTR, None)
133
+ if isinstance(metadata, EventDecoratorMetadata) and metadata.kind == "observer":
134
+ return metadata
135
+ return None
136
+
137
+ def __init__(
138
+ self,
139
+ max_concurrent_observers: int = DEFAULT_MAX_CONCURRENT_OBSERVERS,
140
+ observer_timeout: float = DEFAULT_OBSERVER_TIMEOUT,
141
+ max_sync_workers: int = DEFAULT_MAX_SYNC_WORKERS,
142
+ error_handler: ErrorHandler | None = None,
143
+ use_weak_refs: bool = True,
144
+ batching_config: BatchingConfig | None = None,
145
+ batching_enabled: bool = True,
146
+ ) -> None:
147
+ """Initialize the local observer manager.
148
+
149
+ Args
150
+ ----
151
+ max_concurrent_observers: Maximum number of observers to run concurrently
152
+ observer_timeout: Timeout in seconds for each observer
153
+ max_sync_workers: Maximum thread pool workers for sync observers
154
+ error_handler: Optional error handler, defaults to LoggingErrorHandler
155
+ use_weak_refs: If True, use weak references to prevent memory leaks
156
+ batching_config: Optional batching configuration
157
+ batching_enabled: Toggle to bypass batching (useful for tests/debug)
158
+ """
159
+ self._max_concurrent = max_concurrent_observers
160
+ self._timeout = observer_timeout
161
+ self._error_handler = error_handler or LoggingErrorHandler()
162
+ self._use_weak_refs = use_weak_refs
163
+ self._batching_config = batching_config or BatchingConfig()
164
+
165
+ self._semaphore = asyncio.Semaphore(max_concurrent_observers)
166
+ self._executor = ThreadPoolExecutor(max_workers=max_sync_workers)
167
+ self._executor_shutdown = False
168
+
169
+ self._handlers: dict[str, Any] = {}
170
+
171
+ # Event type filtering and per-observer config
172
+ self._event_filters: dict[str, set[EventType] | None] = {}
173
+ self._observer_timeouts: dict[str, float | None] = {}
174
+ self._observer_semaphores: dict[str, asyncio.Semaphore] = {}
175
+
176
+ if use_weak_refs:
177
+ self._weak_handlers: weakref.WeakValueDictionary = weakref.WeakValueDictionary()
178
+ self._strong_refs: dict[str, Any] = {}
179
+ self._observer_refs: dict[str, weakref.ref] = {}
180
+
181
+ self._batcher: EventBatcher[Event] | None = None
182
+ if batching_enabled:
183
+ self._batcher = EventBatcher(
184
+ self._flush_envelope,
185
+ self._batching_config,
186
+ logger=LOGGER,
187
+ )
188
+
189
+ def _on_observer_deleted(self, observer_id: str) -> None:
190
+ """Cleanup callback when observer is garbage collected."""
191
+ self._event_filters.pop(observer_id, None)
192
+ self._observer_timeouts.pop(observer_id, None)
193
+ self._observer_semaphores.pop(observer_id, None)
194
+
195
+ def _store_observer(self, observer_id: str, observer: Any, keep_alive: bool) -> None:
196
+ """Store observer with appropriate reference type."""
197
+ if self._use_weak_refs:
198
+ try:
199
+ self._weak_handlers[observer_id] = observer
200
+ self._observer_refs[observer_id] = weakref.ref(
201
+ observer, lambda _: self._on_observer_deleted(observer_id)
202
+ )
203
+ if keep_alive:
204
+ self._strong_refs[observer_id] = observer
205
+ except TypeError:
206
+ self._handlers[observer_id] = observer
207
+ self._strong_refs[observer_id] = observer
208
+ else:
209
+ self._handlers[observer_id] = observer
210
+
211
+ def register(
212
+ self,
213
+ handler: Observer | ObserverFunc | AsyncObserverFunc,
214
+ *,
215
+ observer_id: str | None = None,
216
+ event_types: EventTypesInput = None,
217
+ timeout: float | None = None,
218
+ max_concurrency: int | None = None,
219
+ keep_alive: bool = False,
220
+ ) -> str:
221
+ """Register an observer with optional event type filtering."""
222
+
223
+ metadata = self._get_observer_metadata(handler)
224
+
225
+ raw_event_types: EventTypesInput = (
226
+ event_types if event_types is not None else (metadata.event_types if metadata else None)
227
+ )
228
+ normalized_event_types = (
229
+ normalize_event_types(raw_event_types) if raw_event_types is not None else None
230
+ )
231
+
232
+ config = ObserverRegistrationConfig(
233
+ observer_id=(
234
+ observer_id
235
+ if observer_id is not None
236
+ else (metadata.id if metadata and metadata.id else None)
237
+ ),
238
+ event_types=normalized_event_types,
239
+ timeout=(timeout if timeout is not None else (metadata.timeout if metadata else None)),
240
+ max_concurrency=(
241
+ max_concurrency
242
+ if max_concurrency is not None
243
+ else (metadata.max_concurrency if metadata else None)
244
+ ),
245
+ keep_alive=keep_alive,
246
+ )
247
+
248
+ resolved_id = config.observer_id or str(uuid.uuid4())
249
+
250
+ if resolved_id in self._event_filters:
251
+ raise ValueError(f"Observer '{resolved_id}' already registered")
252
+
253
+ if resolved_id in self._handlers:
254
+ raise ValueError(f"Observer '{resolved_id}' already registered")
255
+
256
+ if (
257
+ self._use_weak_refs
258
+ and hasattr(self, "_weak_handlers")
259
+ and resolved_id in self._weak_handlers
260
+ ):
261
+ raise ValueError(f"Observer '{resolved_id}' already registered")
262
+
263
+ keep_alive_flag = config.keep_alive
264
+
265
+ if hasattr(handler, "handle"):
266
+ observer = cast("Observer", handler)
267
+ elif callable(handler):
268
+ observer = FunctionObserver(handler, self._executor)
269
+ keep_alive_flag = True
270
+ else:
271
+ raise TypeError(
272
+ f"Observer must be callable or implement Observer protocol, got {type(handler)}"
273
+ )
274
+
275
+ self._store_observer(resolved_id, observer, keep_alive_flag)
276
+
277
+ self._event_filters[resolved_id] = config.event_types
278
+
279
+ if config.timeout is not None:
280
+ self._observer_timeouts[resolved_id] = config.timeout
281
+ else:
282
+ self._observer_timeouts.pop(resolved_id, None)
283
+
284
+ if config.max_concurrency is not None:
285
+ self._observer_semaphores[resolved_id] = asyncio.Semaphore(config.max_concurrency)
286
+ else:
287
+ self._observer_semaphores.pop(resolved_id, None)
288
+
289
+ return resolved_id
290
+
291
+ def unregister(self, handler_id: str) -> bool:
292
+ """Unregister an observer by ID.
293
+
294
+ Args
295
+ ----
296
+ handler_id: The ID of the observer to unregister
297
+
298
+ Returns
299
+ -------
300
+ bool: True if observer was found and removed, False otherwise
301
+ """
302
+ found = False
303
+
304
+ if handler_id in self._handlers:
305
+ del self._handlers[handler_id]
306
+ found = True
307
+
308
+ if self._use_weak_refs:
309
+ if handler_id in self._weak_handlers:
310
+ del self._weak_handlers[handler_id]
311
+ found = True
312
+
313
+ if handler_id in self._strong_refs:
314
+ del self._strong_refs[handler_id]
315
+ found = True
316
+
317
+ if handler_id in self._event_filters:
318
+ del self._event_filters[handler_id]
319
+
320
+ self._observer_timeouts.pop(handler_id, None)
321
+ self._observer_semaphores.pop(handler_id, None)
322
+
323
+ return found
324
+
325
+ async def notify(self, event: Event) -> None:
326
+ """Notify all interested observers of an event.
327
+
328
+ Only observers registered for this event type will be notified.
329
+ Errors are handled according to the configured error handler
330
+ but don't affect execution.
331
+
332
+ Args
333
+ ----
334
+ event: The event to distribute to observers
335
+ """
336
+ active_observers = self._collect_active_observers()
337
+ if not active_observers:
338
+ return
339
+
340
+ if self._batcher is not None:
341
+ await self._batcher.add(event)
342
+ return
343
+
344
+ observers = self._collect_interested_observers(event, active_observers)
345
+ if not observers:
346
+ return
347
+
348
+ tasks = [
349
+ self._dispatch_events(observer_id, observer, (event,))
350
+ for observer_id, observer in observers.items()
351
+ ]
352
+
353
+ await self._run_with_timeout(
354
+ tasks,
355
+ type(event).__name__,
356
+ is_critical=self._is_priority_event(event),
357
+ )
358
+
359
+ def clear(self) -> None:
360
+ """Remove all registered observers."""
361
+ self._handlers.clear()
362
+ self._event_filters.clear()
363
+ self._observer_timeouts.clear()
364
+ self._observer_semaphores.clear()
365
+
366
+ if self._use_weak_refs:
367
+ self._weak_handlers.clear()
368
+ self._strong_refs.clear()
369
+ self._observer_refs.clear()
370
+
371
+ async def close(self) -> None:
372
+ """Close the manager and cleanup resources."""
373
+ if self._batcher is not None:
374
+ await self._batcher.close()
375
+
376
+ self.clear()
377
+ if not self._executor_shutdown:
378
+ self._executor.shutdown(wait=False)
379
+ self._executor_shutdown = True
380
+
381
+ def __len__(self) -> int:
382
+ """Return number of registered observers.
383
+
384
+ Returns
385
+ -------
386
+ int: Count of active observers (including weak refs that are still alive)
387
+ """
388
+ count = len(self._handlers)
389
+
390
+ if self._use_weak_refs:
391
+ for handler_id in list(self._weak_handlers.keys()):
392
+ if (
393
+ handler_id not in self._handlers
394
+ and self._weak_handlers.get(handler_id) is not None
395
+ ):
396
+ count += 1
397
+
398
+ return count
399
+
400
+ @property
401
+ def batching_metrics(self) -> BatchingMetrics | None:
402
+ """Expose batching metrics when batching is enabled."""
403
+
404
+ if self._batcher is None:
405
+ return None
406
+ return self._batcher.metrics
407
+
408
+ def __enter__(self) -> LocalObserverManager:
409
+ """Context manager entry.
410
+
411
+ Returns
412
+ -------
413
+ Self for use in with statements
414
+ """
415
+ return self
416
+
417
+ def __exit__(self, _exc_type: Any, _exc_val: Any, _exc_tb: Any) -> None:
418
+ """Context manager exit with cleanup.
419
+
420
+ Args
421
+ ----
422
+ _exc_type: Exception type if an exception occurred
423
+ _exc_val: Exception value if an exception occurred
424
+ _exc_tb: Exception traceback if an exception occurred
425
+ """
426
+ if not self._executor_shutdown:
427
+ self._executor.shutdown(wait=True)
428
+ self._executor_shutdown = True
429
+
430
+ async def __aenter__(self) -> LocalObserverManager:
431
+ """Async context manager entry.
432
+
433
+ Returns
434
+ -------
435
+ Self for use in async with statements
436
+ """
437
+ return self
438
+
439
+ async def __aexit__(self, _exc_type: Any, _exc_val: Any, _exc_tb: Any) -> None:
440
+ """Async context manager exit with cleanup.
441
+
442
+ Args
443
+ ----
444
+ _exc_type: Exception type if an exception occurred
445
+ _exc_val: Exception value if an exception occurred
446
+ _exc_tb: Exception traceback if an exception occurred
447
+ """
448
+ await self.close()
449
+
450
+ # Private helper methods
451
+
452
+ async def _flush_envelope(self, envelope: EventBatchEnvelope) -> None:
453
+ """Flush a prepared event envelope to interested observers."""
454
+
455
+ active_observers = self._collect_active_observers()
456
+ if not active_observers:
457
+ return
458
+
459
+ tasks = []
460
+ batch_contains_priority = False
461
+
462
+ for observer_id, observer in active_observers.items():
463
+ events = self._filter_events_for_observer(observer_id, envelope.events)
464
+ if not events:
465
+ continue
466
+
467
+ if any(self._is_priority_event(event) for event in events):
468
+ batch_contains_priority = True
469
+
470
+ batch_envelope = None
471
+ if self._supports_batch(observer):
472
+ batch_envelope = self._make_filtered_envelope(envelope, events)
473
+
474
+ tasks.append(self._dispatch_events(observer_id, observer, events, batch_envelope))
475
+
476
+ if not tasks:
477
+ return
478
+
479
+ await self._run_with_timeout(
480
+ tasks,
481
+ f"batch:{envelope.flush_reason.value}",
482
+ is_critical=batch_contains_priority,
483
+ )
484
+
485
+ def _collect_active_observers(self) -> dict[str, Observer]:
486
+ """Collect currently active observers, pruning dead weak references."""
487
+
488
+ observers: dict[str, Observer] = dict(self._handlers)
489
+
490
+ if self._use_weak_refs:
491
+ for observer_id in list(self._weak_handlers.keys()):
492
+ observer = self._weak_handlers.get(observer_id)
493
+ if observer is None:
494
+ self._weak_handlers.pop(observer_id, None)
495
+ self._event_filters.pop(observer_id, None)
496
+ self._strong_refs.pop(observer_id, None)
497
+ continue
498
+ observers.setdefault(observer_id, observer)
499
+
500
+ return observers
501
+
502
+ def _collect_interested_observers(
503
+ self, event: Event, observers: dict[str, Observer]
504
+ ) -> dict[str, Observer]:
505
+ """Filter observers down to those interested in the given event."""
506
+
507
+ return {
508
+ observer_id: observer
509
+ for observer_id, observer in observers.items()
510
+ if self._should_notify(observer_id, event)
511
+ }
512
+
513
+ async def _dispatch_events(
514
+ self,
515
+ observer_id: str,
516
+ observer: Observer,
517
+ events: Sequence[Event],
518
+ envelope: EventBatchEnvelope | None = None,
519
+ ) -> None:
520
+ """Dispatch one or more events to an observer under concurrency control."""
521
+
522
+ per_observer_semaphore = self._observer_semaphores.get(observer_id)
523
+
524
+ async def _execute() -> None:
525
+ if envelope is not None and self._supports_batch(observer):
526
+ await self._safe_invoke_batch(observer_id, observer, envelope, events)
527
+ else:
528
+ for event in events:
529
+ await self._safe_invoke(observer_id, observer, event)
530
+
531
+ if per_observer_semaphore is None:
532
+ async with self._semaphore:
533
+ await _execute()
534
+ return
535
+
536
+ async with self._semaphore, per_observer_semaphore:
537
+ await _execute()
538
+
539
+ def _filter_events_for_observer(
540
+ self, observer_id: str, events: Sequence[Event]
541
+ ) -> tuple[Event, ...]:
542
+ """Return events from the batch that match the observer's filter."""
543
+
544
+ event_filter = self._event_filters.get(observer_id)
545
+ if event_filter is None:
546
+ return tuple(events)
547
+
548
+ return tuple(event for event in events if type(event) in event_filter)
549
+
550
+ def _make_filtered_envelope(
551
+ self, envelope: EventBatchEnvelope, events: Sequence[Event]
552
+ ) -> EventBatchEnvelope:
553
+ """Create envelope tailored to an observer's filtered events."""
554
+
555
+ if len(events) == len(envelope.events):
556
+ return envelope
557
+
558
+ return EventBatchEnvelope(
559
+ batch_id=envelope.batch_id,
560
+ sequence_no=envelope.sequence_no,
561
+ created_at=envelope.created_at,
562
+ events=tuple(events),
563
+ flush_reason=envelope.flush_reason,
564
+ )
565
+
566
+ def _supports_batch(self, observer: Observer) -> bool:
567
+ """Check whether observer exposes a batch handler."""
568
+
569
+ handler = getattr(observer, "handle_batch", None)
570
+ return callable(handler)
571
+
572
+ async def _run_with_timeout(
573
+ self,
574
+ tasks: Sequence[Awaitable[Any]],
575
+ context_label: str,
576
+ *,
577
+ is_critical: bool = False,
578
+ ) -> None:
579
+ """Run observer tasks enforcing a global timeout."""
580
+
581
+ if not tasks:
582
+ return
583
+
584
+ total_timeout = self._timeout + (len(tasks) * DEFAULT_CLEANUP_INTERVAL)
585
+
586
+ try:
587
+ await asyncio.wait_for(
588
+ asyncio.gather(*tasks, return_exceptions=True),
589
+ timeout=total_timeout,
590
+ )
591
+ except TimeoutError:
592
+ self._error_handler.handle_error(
593
+ TimeoutError(f"Observer notification timed out after {total_timeout}s"),
594
+ {
595
+ "event_type": context_label,
596
+ "handler_name": "LocalObserverManager",
597
+ "is_critical": is_critical,
598
+ },
599
+ )
600
+
601
+ def _should_notify(self, observer_id: str, event: Event) -> bool:
602
+ """Check if observer should be notified of this event type."""
603
+ event_filter = self._event_filters.get(observer_id)
604
+
605
+ if event_filter is None:
606
+ return True
607
+
608
+ # Check if event type matches any allowed type (supports subclassing)
609
+ return isinstance(event, tuple(event_filter))
610
+
611
+ async def _safe_invoke(self, observer_id: str, observer: Observer, event: Event) -> None:
612
+ """Safely invoke an observer with timeout."""
613
+ timeout_value = self._observer_timeouts.get(observer_id, self._timeout)
614
+ try:
615
+ if timeout_value is None:
616
+ await observer.handle(event)
617
+ else:
618
+ await asyncio.wait_for(observer.handle(event), timeout=timeout_value)
619
+ except TimeoutError as exc:
620
+ name = getattr(observer, "__name__", observer.__class__.__name__)
621
+ self._error_handler.handle_error(
622
+ exc,
623
+ {
624
+ "handler_name": name,
625
+ "event_type": type(event).__name__,
626
+ "is_critical": self._is_priority_event(event),
627
+ },
628
+ )
629
+ except Exception as exc:
630
+ name = getattr(observer, "__name__", observer.__class__.__name__)
631
+ self._error_handler.handle_error(
632
+ exc,
633
+ {
634
+ "handler_name": name,
635
+ "event_type": type(event).__name__,
636
+ "is_critical": self._is_priority_event(event),
637
+ },
638
+ )
639
+
640
+ async def _safe_invoke_batch(
641
+ self,
642
+ observer_id: str,
643
+ observer: Observer,
644
+ envelope: EventBatchEnvelope,
645
+ events: Sequence[Event],
646
+ ) -> None:
647
+ """Safely invoke an observer's batch handler."""
648
+
649
+ handler = getattr(observer, "handle_batch", None)
650
+ if handler is None:
651
+ for event in events:
652
+ await self._safe_invoke(observer_id, observer, event)
653
+ return
654
+
655
+ timeout_value = self._observer_timeouts.get(observer_id, self._timeout)
656
+
657
+ loop = asyncio.get_running_loop()
658
+
659
+ try:
660
+ if inspect.iscoroutinefunction(handler):
661
+ coroutine = handler(envelope)
662
+ if timeout_value is None:
663
+ await coroutine
664
+ else:
665
+ await asyncio.wait_for(coroutine, timeout=timeout_value)
666
+ else:
667
+ task = loop.run_in_executor(self._executor, handler, envelope)
668
+ if timeout_value is None:
669
+ await task
670
+ else:
671
+ await asyncio.wait_for(task, timeout=timeout_value)
672
+ except TimeoutError as e:
673
+ name = getattr(observer, "__name__", observer.__class__.__name__)
674
+ self._error_handler.handle_error(
675
+ e,
676
+ {
677
+ "handler_name": name,
678
+ "event_type": f"batch:{envelope.flush_reason.value}",
679
+ "is_critical": any(self._is_priority_event(event) for event in events),
680
+ },
681
+ )
682
+ except Exception as e:
683
+ name = getattr(observer, "__name__", observer.__class__.__name__)
684
+ self._error_handler.handle_error(
685
+ e,
686
+ {
687
+ "handler_name": name,
688
+ "event_type": f"batch:{envelope.flush_reason.value}",
689
+ "is_critical": any(self._is_priority_event(event) for event in events),
690
+ },
691
+ )
692
+
693
+ def _is_priority_event(self, event: Event) -> bool:
694
+ """Check if event is considered priority/critical."""
695
+
696
+ return isinstance(event, self._batching_config.priority_event_types)