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,350 @@
1
+ """Tests for Azure Blob Storage adapter."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from hexdag_plugins.azure.azure_blob_adapter import AzureBlobAdapter
8
+
9
+
10
+ @pytest.fixture
11
+ def blob_adapter():
12
+ """Create Azure Blob adapter for testing."""
13
+ return AzureBlobAdapter(
14
+ connection_string="DefaultEndpointsProtocol=https;AccountName=test;AccountKey=testkey123;EndpointSuffix=core.windows.net",
15
+ container_name="test-container",
16
+ )
17
+
18
+
19
+ @pytest.fixture
20
+ def blob_adapter_managed_identity():
21
+ """Create Azure Blob adapter with managed identity."""
22
+ return AzureBlobAdapter(
23
+ account_url="https://teststorage.blob.core.windows.net",
24
+ container_name="test-container",
25
+ use_managed_identity=True,
26
+ )
27
+
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_adapter_initialization(blob_adapter):
31
+ """Test adapter initializes with correct parameters."""
32
+ assert blob_adapter.container_name == "test-container"
33
+ assert blob_adapter.use_managed_identity is False
34
+ assert blob_adapter._container_client is None
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_adapter_initialization_managed_identity(blob_adapter_managed_identity):
39
+ """Test adapter initializes with managed identity."""
40
+ assert blob_adapter_managed_identity.account_url == "https://teststorage.blob.core.windows.net"
41
+ assert blob_adapter_managed_identity.use_managed_identity is True
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_upload_success(blob_adapter):
46
+ """Test successful blob upload."""
47
+ mock_blob_client = AsyncMock()
48
+ mock_blob_client.url = "https://test.blob.core.windows.net/test-container/test.txt"
49
+ mock_blob_client.upload_blob = AsyncMock()
50
+
51
+ mock_container = AsyncMock()
52
+ mock_container.get_blob_client.return_value = mock_blob_client
53
+
54
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
55
+ url = await blob_adapter.aupload("test.txt", b"hello world")
56
+
57
+ assert url == "https://test.blob.core.windows.net/test-container/test.txt"
58
+ mock_blob_client.upload_blob.assert_called_once()
59
+
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_upload_string(blob_adapter):
63
+ """Test uploading string data."""
64
+ mock_blob_client = AsyncMock()
65
+ mock_blob_client.url = "https://test.blob.core.windows.net/test-container/test.txt"
66
+ mock_blob_client.upload_blob = AsyncMock()
67
+
68
+ mock_container = AsyncMock()
69
+ mock_container.get_blob_client.return_value = mock_blob_client
70
+
71
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
72
+ await blob_adapter.aupload("test.txt", "hello string")
73
+
74
+ call_args = mock_blob_client.upload_blob.call_args
75
+ assert call_args.kwargs.get("content_type") == "text/plain"
76
+
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_download_success(blob_adapter):
80
+ """Test successful blob download."""
81
+ mock_stream = AsyncMock()
82
+ mock_stream.readall = AsyncMock(return_value=b"downloaded content")
83
+
84
+ mock_blob_client = AsyncMock()
85
+ mock_blob_client.download_blob = AsyncMock(return_value=mock_stream)
86
+
87
+ mock_container = AsyncMock()
88
+ mock_container.get_blob_client.return_value = mock_blob_client
89
+
90
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
91
+ content = await blob_adapter.adownload("test.txt")
92
+
93
+ assert content == b"downloaded content"
94
+
95
+
96
+ @pytest.mark.asyncio
97
+ async def test_download_not_found(blob_adapter):
98
+ """Test download raises FileNotFoundError for missing blob."""
99
+ mock_blob_client = AsyncMock()
100
+ mock_blob_client.download_blob = AsyncMock(
101
+ side_effect=Exception("BlobNotFound: The specified blob does not exist")
102
+ )
103
+
104
+ mock_container = AsyncMock()
105
+ mock_container.get_blob_client.return_value = mock_blob_client
106
+
107
+ with (
108
+ patch.object(blob_adapter, "_get_container", return_value=mock_container),
109
+ pytest.raises(FileNotFoundError, match="not found"),
110
+ ):
111
+ await blob_adapter.adownload("missing.txt")
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_download_text(blob_adapter):
116
+ """Test downloading blob as text."""
117
+ mock_stream = AsyncMock()
118
+ mock_stream.readall = AsyncMock(return_value=b"text content")
119
+
120
+ mock_blob_client = AsyncMock()
121
+ mock_blob_client.download_blob = AsyncMock(return_value=mock_stream)
122
+
123
+ mock_container = AsyncMock()
124
+ mock_container.get_blob_client.return_value = mock_blob_client
125
+
126
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
127
+ text = await blob_adapter.adownload_text("test.txt")
128
+
129
+ assert text == "text content"
130
+
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_delete_success(blob_adapter):
134
+ """Test successful blob deletion."""
135
+ mock_blob_client = AsyncMock()
136
+ mock_blob_client.delete_blob = AsyncMock()
137
+
138
+ mock_container = AsyncMock()
139
+ mock_container.get_blob_client.return_value = mock_blob_client
140
+
141
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
142
+ result = await blob_adapter.adelete("test.txt")
143
+
144
+ assert result is True
145
+ mock_blob_client.delete_blob.assert_called_once()
146
+
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_delete_not_found(blob_adapter):
150
+ """Test delete returns False for missing blob."""
151
+ mock_blob_client = AsyncMock()
152
+ mock_blob_client.delete_blob = AsyncMock(side_effect=Exception("BlobNotFound"))
153
+
154
+ mock_container = AsyncMock()
155
+ mock_container.get_blob_client.return_value = mock_blob_client
156
+
157
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
158
+ result = await blob_adapter.adelete("missing.txt")
159
+
160
+ assert result is False
161
+
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_exists_true(blob_adapter):
165
+ """Test exists returns True for existing blob."""
166
+ mock_blob_client = AsyncMock()
167
+ mock_blob_client.exists = AsyncMock(return_value=True)
168
+
169
+ mock_container = AsyncMock()
170
+ mock_container.get_blob_client.return_value = mock_blob_client
171
+
172
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
173
+ result = await blob_adapter.aexists("test.txt")
174
+
175
+ assert result is True
176
+
177
+
178
+ @pytest.mark.asyncio
179
+ async def test_exists_false(blob_adapter):
180
+ """Test exists returns False for missing blob."""
181
+ mock_blob_client = AsyncMock()
182
+ mock_blob_client.exists = AsyncMock(return_value=False)
183
+
184
+ mock_container = AsyncMock()
185
+ mock_container.get_blob_client.return_value = mock_blob_client
186
+
187
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
188
+ result = await blob_adapter.aexists("missing.txt")
189
+
190
+ assert result is False
191
+
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_list_blobs(blob_adapter):
195
+ """Test listing blobs."""
196
+ # Create mock blobs
197
+ mock_blob1 = MagicMock()
198
+ mock_blob1.name = "file1.txt"
199
+ mock_blob1.size = 100
200
+ mock_blob1.last_modified = "2024-01-01"
201
+ mock_blob1.content_settings = MagicMock()
202
+ mock_blob1.content_settings.content_type = "text/plain"
203
+ mock_blob1.metadata = {"key": "value"}
204
+
205
+ mock_blob2 = MagicMock()
206
+ mock_blob2.name = "file2.txt"
207
+ mock_blob2.size = 200
208
+ mock_blob2.last_modified = "2024-01-02"
209
+ mock_blob2.content_settings = None
210
+ mock_blob2.metadata = None
211
+
212
+ # Create async iterator
213
+ async def mock_list_blobs(name_starts_with=None):
214
+ for blob in [mock_blob1, mock_blob2]:
215
+ yield blob
216
+
217
+ mock_container = AsyncMock()
218
+ mock_container.list_blobs = mock_list_blobs
219
+
220
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
221
+ results = await blob_adapter.alist()
222
+
223
+ assert len(results) == 2
224
+ assert results[0]["name"] == "file1.txt"
225
+ assert results[0]["size"] == 100
226
+ assert results[1]["name"] == "file2.txt"
227
+
228
+
229
+ @pytest.mark.asyncio
230
+ async def test_list_blobs_with_prefix(blob_adapter):
231
+ """Test listing blobs with prefix filter."""
232
+ mock_blob = MagicMock()
233
+ mock_blob.name = "reports/file1.txt"
234
+ mock_blob.size = 100
235
+ mock_blob.last_modified = "2024-01-01"
236
+ mock_blob.content_settings = None
237
+ mock_blob.metadata = None
238
+
239
+ async def mock_list_blobs(name_starts_with=None):
240
+ if name_starts_with == "reports/":
241
+ yield mock_blob
242
+
243
+ mock_container = AsyncMock()
244
+ mock_container.list_blobs = mock_list_blobs
245
+
246
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
247
+ results = await blob_adapter.alist(prefix="reports/")
248
+
249
+ assert len(results) == 1
250
+ assert results[0]["name"] == "reports/file1.txt"
251
+
252
+
253
+ @pytest.mark.asyncio
254
+ async def test_upload_json(blob_adapter):
255
+ """Test uploading JSON data."""
256
+ mock_blob_client = AsyncMock()
257
+ mock_blob_client.url = "https://test.blob.core.windows.net/test-container/data.json"
258
+ mock_blob_client.upload_blob = AsyncMock()
259
+
260
+ mock_container = AsyncMock()
261
+ mock_container.get_blob_client.return_value = mock_blob_client
262
+
263
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
264
+ url = await blob_adapter.aupload_json("data.json", {"key": "value"})
265
+
266
+ assert url == "https://test.blob.core.windows.net/test-container/data.json"
267
+ call_args = mock_blob_client.upload_blob.call_args
268
+ assert call_args.kwargs.get("content_type") == "application/json"
269
+
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_download_json(blob_adapter):
273
+ """Test downloading and parsing JSON."""
274
+ mock_stream = AsyncMock()
275
+ mock_stream.readall = AsyncMock(return_value=b'{"key": "value"}')
276
+
277
+ mock_blob_client = AsyncMock()
278
+ mock_blob_client.download_blob = AsyncMock(return_value=mock_stream)
279
+
280
+ mock_container = AsyncMock()
281
+ mock_container.get_blob_client.return_value = mock_blob_client
282
+
283
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
284
+ data = await blob_adapter.adownload_json("data.json")
285
+
286
+ assert data == {"key": "value"}
287
+
288
+
289
+ @pytest.mark.asyncio
290
+ async def test_health_check_healthy(blob_adapter):
291
+ """Test health check returns healthy status."""
292
+ mock_blob = MagicMock()
293
+ mock_blob.name = "file.txt"
294
+
295
+ async def mock_list_blobs():
296
+ yield mock_blob
297
+
298
+ mock_container = AsyncMock()
299
+ mock_container.list_blobs = mock_list_blobs
300
+
301
+ with patch.object(blob_adapter, "_get_container", return_value=mock_container):
302
+ status = await blob_adapter.ahealth_check()
303
+
304
+ assert status.status == "healthy"
305
+ assert status.adapter_name == "AzureBlob"
306
+ assert status.details["container"] == "test-container"
307
+
308
+
309
+ @pytest.mark.asyncio
310
+ async def test_health_check_unhealthy(blob_adapter):
311
+ """Test health check returns unhealthy on error."""
312
+ with patch.object(blob_adapter, "_get_container", side_effect=Exception("Connection failed")):
313
+ status = await blob_adapter.ahealth_check()
314
+
315
+ assert status.status == "unhealthy"
316
+ assert "error" in status.details
317
+
318
+
319
+ @pytest.mark.asyncio
320
+ async def test_to_dict(blob_adapter):
321
+ """Test serialization excludes secrets."""
322
+ config = blob_adapter.to_dict()
323
+
324
+ assert "container_name" in config
325
+ assert config["container_name"] == "test-container"
326
+ assert "connection_string" not in config # Secret excluded
327
+
328
+
329
+ @pytest.mark.asyncio
330
+ async def test_managed_identity_requires_account_url():
331
+ """Test managed identity requires account_url."""
332
+ adapter = AzureBlobAdapter(
333
+ use_managed_identity=True,
334
+ container_name="test",
335
+ )
336
+
337
+ with pytest.raises(ValueError, match="account_url is required"):
338
+ await adapter._get_container()
339
+
340
+
341
+ @pytest.mark.asyncio
342
+ async def test_connection_string_required_without_managed_identity():
343
+ """Test connection_string required without managed identity."""
344
+ adapter = AzureBlobAdapter(
345
+ use_managed_identity=False,
346
+ container_name="test",
347
+ )
348
+
349
+ with pytest.raises(ValueError, match="connection_string is required"):
350
+ await adapter._get_container()
@@ -0,0 +1,323 @@
1
+ """Tests for Azure Cosmos DB adapter."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from hexdag_plugins.azure.azure_cosmos_adapter import AzureCosmosAdapter
8
+
9
+
10
+ @pytest.fixture
11
+ def cosmos_adapter():
12
+ """Create Azure Cosmos adapter for testing."""
13
+ return AzureCosmosAdapter(
14
+ endpoint="https://test-cosmos.documents.azure.com:443/",
15
+ key="test-key-123",
16
+ database_name="test-db",
17
+ container_name="test-container",
18
+ )
19
+
20
+
21
+ @pytest.fixture
22
+ def cosmos_adapter_managed_identity():
23
+ """Create Azure Cosmos adapter with managed identity."""
24
+ return AzureCosmosAdapter(
25
+ endpoint="https://test-cosmos.documents.azure.com:443/",
26
+ database_name="test-db",
27
+ container_name="test-container",
28
+ use_managed_identity=True,
29
+ )
30
+
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_adapter_initialization(cosmos_adapter):
34
+ """Test adapter initializes with correct parameters."""
35
+ assert cosmos_adapter.endpoint == "https://test-cosmos.documents.azure.com:443/"
36
+ assert cosmos_adapter.database_name == "test-db"
37
+ assert cosmos_adapter.container_name == "test-container"
38
+ assert cosmos_adapter.partition_key == "/agent_id"
39
+ assert cosmos_adapter.use_managed_identity is False
40
+ assert cosmos_adapter._container is None
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_store_success(cosmos_adapter):
45
+ """Test successful data storage."""
46
+ mock_container = AsyncMock()
47
+ mock_container.upsert_item = AsyncMock()
48
+
49
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
50
+ await cosmos_adapter.astore("agent-1", {"context": "test data"})
51
+
52
+ mock_container.upsert_item.assert_called_once()
53
+ call_args = mock_container.upsert_item.call_args
54
+ document = call_args[0][0]
55
+ assert document["id"] == "agent-1"
56
+ assert document["agent_id"] == "agent-1"
57
+ assert document["data"] == {"context": "test data"}
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_store_with_composite_key(cosmos_adapter):
62
+ """Test storage with composite key extracts agent_id."""
63
+ mock_container = AsyncMock()
64
+ mock_container.upsert_item = AsyncMock()
65
+
66
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
67
+ await cosmos_adapter.astore("agent-1:conversation:session-1", {"messages": []})
68
+
69
+ call_args = mock_container.upsert_item.call_args
70
+ document = call_args[0][0]
71
+ assert document["id"] == "agent-1:conversation:session-1"
72
+ assert document["agent_id"] == "agent-1"
73
+
74
+
75
+ @pytest.mark.asyncio
76
+ async def test_store_with_metadata(cosmos_adapter):
77
+ """Test storage with metadata."""
78
+ mock_container = AsyncMock()
79
+ mock_container.upsert_item = AsyncMock()
80
+
81
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
82
+ await cosmos_adapter.astore("agent-1", {"data": "value"}, metadata={"type": "context"})
83
+
84
+ call_args = mock_container.upsert_item.call_args
85
+ document = call_args[0][0]
86
+ assert document["metadata"] == {"type": "context"}
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_retrieve_success(cosmos_adapter):
91
+ """Test successful data retrieval."""
92
+ mock_item = {"id": "agent-1", "data": {"context": "test"}}
93
+
94
+ mock_container = AsyncMock()
95
+ mock_container.read_item = AsyncMock(return_value=mock_item)
96
+
97
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
98
+ result = await cosmos_adapter.aretrieve("agent-1")
99
+
100
+ assert result == {"context": "test"}
101
+ mock_container.read_item.assert_called_once_with(item="agent-1", partition_key="agent-1")
102
+
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_retrieve_not_found(cosmos_adapter):
106
+ """Test retrieval returns None for missing key."""
107
+ mock_container = AsyncMock()
108
+ mock_container.read_item = AsyncMock(side_effect=Exception("NotFound"))
109
+
110
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
111
+ result = await cosmos_adapter.aretrieve("missing-key")
112
+
113
+ assert result is None
114
+
115
+
116
+ @pytest.mark.asyncio
117
+ async def test_delete_success(cosmos_adapter):
118
+ """Test successful deletion."""
119
+ mock_container = AsyncMock()
120
+ mock_container.delete_item = AsyncMock()
121
+
122
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
123
+ result = await cosmos_adapter.adelete("agent-1")
124
+
125
+ assert result is True
126
+ mock_container.delete_item.assert_called_once_with(item="agent-1", partition_key="agent-1")
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_delete_not_found(cosmos_adapter):
131
+ """Test deletion returns False for missing key."""
132
+ mock_container = AsyncMock()
133
+ mock_container.delete_item = AsyncMock(side_effect=Exception("NotFound"))
134
+
135
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
136
+ result = await cosmos_adapter.adelete("missing-key")
137
+
138
+ assert result is False
139
+
140
+
141
+ @pytest.mark.asyncio
142
+ async def test_list_all_keys(cosmos_adapter):
143
+ """Test listing all keys."""
144
+ mock_items = [{"id": "key-1"}, {"id": "key-2"}]
145
+
146
+ async def mock_iter():
147
+ for item in mock_items:
148
+ yield item
149
+
150
+ mock_container = MagicMock()
151
+ mock_container.query_items.return_value = mock_iter()
152
+
153
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
154
+ keys = await cosmos_adapter.alist()
155
+
156
+ assert keys == ["key-1", "key-2"]
157
+ mock_container.query_items.assert_called_once()
158
+
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_list_with_prefix(cosmos_adapter):
162
+ """Test listing keys with prefix filter."""
163
+ mock_items = [{"id": "agent-1:conv"}, {"id": "agent-1:ctx"}]
164
+
165
+ async def mock_iter():
166
+ for item in mock_items:
167
+ yield item
168
+
169
+ mock_container = MagicMock()
170
+ mock_container.query_items.return_value = mock_iter()
171
+
172
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
173
+ keys = await cosmos_adapter.alist(prefix="agent-1")
174
+
175
+ assert len(keys) == 2
176
+ call_args = mock_container.query_items.call_args
177
+ assert "STARTSWITH" in call_args[1]["query"]
178
+
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_store_conversation(cosmos_adapter):
182
+ """Test storing conversation history."""
183
+ mock_container = AsyncMock()
184
+ mock_container.upsert_item = AsyncMock()
185
+
186
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
187
+ messages = [
188
+ {"role": "user", "content": "Hello"},
189
+ {"role": "assistant", "content": "Hi!"},
190
+ ]
191
+ await cosmos_adapter.astore_conversation("agent-1", messages, "session-1")
192
+
193
+ call_args = mock_container.upsert_item.call_args
194
+ document = call_args[0][0]
195
+ assert document["id"] == "agent-1:conversation:session-1"
196
+ assert document["data"]["messages"] == messages
197
+
198
+
199
+ @pytest.mark.asyncio
200
+ async def test_retrieve_conversation(cosmos_adapter):
201
+ """Test retrieving conversation history."""
202
+ mock_item = {
203
+ "id": "agent-1:conversation:session-1",
204
+ "data": {
205
+ "messages": [
206
+ {"role": "user", "content": "Hello"},
207
+ {"role": "assistant", "content": "Hi!"},
208
+ ]
209
+ },
210
+ }
211
+
212
+ mock_container = AsyncMock()
213
+ mock_container.read_item = AsyncMock(return_value=mock_item)
214
+
215
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
216
+ messages = await cosmos_adapter.aretrieve_conversation("agent-1", "session-1")
217
+
218
+ assert len(messages) == 2
219
+ assert messages[0]["role"] == "user"
220
+
221
+
222
+ @pytest.mark.asyncio
223
+ async def test_retrieve_conversation_not_found(cosmos_adapter):
224
+ """Test retrieving missing conversation returns empty list."""
225
+ mock_container = AsyncMock()
226
+ mock_container.read_item = AsyncMock(side_effect=Exception("NotFound"))
227
+
228
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
229
+ messages = await cosmos_adapter.aretrieve_conversation("agent-1", "missing")
230
+
231
+ assert messages == []
232
+
233
+
234
+ @pytest.mark.asyncio
235
+ async def test_clear_agent(cosmos_adapter):
236
+ """Test clearing all agent data."""
237
+ mock_items = [{"id": "agent-1:conv"}, {"id": "agent-1:ctx"}]
238
+
239
+ async def mock_iter():
240
+ for item in mock_items:
241
+ yield item
242
+
243
+ mock_container = MagicMock()
244
+ mock_container.query_items.return_value = mock_iter()
245
+ mock_container.delete_item = AsyncMock()
246
+
247
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
248
+ count = await cosmos_adapter.aclear_agent("agent-1")
249
+
250
+ assert count == 2
251
+
252
+
253
+ @pytest.mark.asyncio
254
+ async def test_search(cosmos_adapter):
255
+ """Test searching memories."""
256
+ mock_items = [
257
+ {"id": "key-1", "data": {"content": "test query"}, "metadata": {}, "created_at": 1234567890}
258
+ ]
259
+
260
+ async def mock_iter():
261
+ for item in mock_items:
262
+ yield item
263
+
264
+ mock_container = MagicMock()
265
+ mock_container.query_items.return_value = mock_iter()
266
+
267
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
268
+ results = await cosmos_adapter.asearch("test", top_k=5)
269
+
270
+ assert len(results) == 1
271
+ call_args = mock_container.query_items.call_args
272
+ assert "CONTAINS" in call_args[1]["query"]
273
+
274
+
275
+ @pytest.mark.asyncio
276
+ async def test_health_check_healthy(cosmos_adapter):
277
+ """Test health check returns healthy status."""
278
+
279
+ async def mock_iter():
280
+ yield 42 # Document count
281
+
282
+ mock_container = MagicMock()
283
+ mock_container.query_items.return_value = mock_iter()
284
+
285
+ with patch.object(cosmos_adapter, "_get_container", return_value=mock_container):
286
+ status = await cosmos_adapter.ahealth_check()
287
+
288
+ assert status.status == "healthy"
289
+ assert status.adapter_name == "AzureCosmos"
290
+ assert status.details["database"] == "test-db"
291
+
292
+
293
+ @pytest.mark.asyncio
294
+ async def test_health_check_unhealthy(cosmos_adapter):
295
+ """Test health check returns unhealthy on error."""
296
+ with patch.object(cosmos_adapter, "_get_container", side_effect=Exception("Connection failed")):
297
+ status = await cosmos_adapter.ahealth_check()
298
+
299
+ assert status.status == "unhealthy"
300
+ assert "error" in status.details
301
+
302
+
303
+ @pytest.mark.asyncio
304
+ async def test_to_dict(cosmos_adapter):
305
+ """Test serialization excludes secrets."""
306
+ config = cosmos_adapter.to_dict()
307
+
308
+ assert "endpoint" in config
309
+ assert "database_name" in config
310
+ assert "key" not in config # Secret excluded
311
+
312
+
313
+ @pytest.mark.asyncio
314
+ async def test_key_required_without_managed_identity():
315
+ """Test key is required without managed identity."""
316
+ adapter = AzureCosmosAdapter(
317
+ endpoint="https://test.documents.azure.com:443/",
318
+ use_managed_identity=False,
319
+ database_name="test",
320
+ )
321
+
322
+ with pytest.raises(ValueError, match="key is required"):
323
+ await adapter._get_container()