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,207 @@
1
+ """File operations API for hexdag studio.
2
+
3
+ Provides endpoints for listing, reading, and writing YAML pipeline files.
4
+ All operations work on local filesystem - no cloud storage.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ from fastapi import APIRouter, HTTPException, Query
11
+ from pydantic import BaseModel
12
+
13
+ router = APIRouter(prefix="/files", tags=["files"])
14
+
15
+
16
+ class FileInfo(BaseModel):
17
+ """File metadata."""
18
+
19
+ name: str
20
+ path: str
21
+ is_directory: bool
22
+ size: int | None = None
23
+ modified: float | None = None
24
+
25
+
26
+ class FileContent(BaseModel):
27
+ """File content with metadata."""
28
+
29
+ path: str
30
+ content: str
31
+ modified: float
32
+
33
+
34
+ class SaveRequest(BaseModel):
35
+ """Request to save file content."""
36
+
37
+ content: str
38
+
39
+
40
+ class FileListResponse(BaseModel):
41
+ """Response containing list of files."""
42
+
43
+ root: str
44
+ files: list[FileInfo]
45
+
46
+
47
+ # Global workspace root - set by studio server on startup
48
+ _workspace_root: Path | None = None
49
+
50
+
51
+ def set_workspace_root(path: Path) -> None:
52
+ """Set the workspace root directory."""
53
+ global _workspace_root
54
+ _workspace_root = path
55
+
56
+
57
+ def get_workspace_root() -> Path:
58
+ """Get the workspace root directory."""
59
+ if _workspace_root is None:
60
+ raise HTTPException(status_code=500, detail="Workspace root not configured")
61
+ return _workspace_root
62
+
63
+
64
+ def _resolve_path(relative_path: str) -> Path:
65
+ """Resolve a relative path within the workspace.
66
+
67
+ Prevents directory traversal attacks.
68
+ """
69
+ root = get_workspace_root()
70
+ resolved = (root / relative_path).resolve()
71
+
72
+ # Security: ensure path is within workspace
73
+ if not str(resolved).startswith(str(root.resolve())):
74
+ raise HTTPException(status_code=403, detail="Access denied: path outside workspace")
75
+
76
+ return resolved
77
+
78
+
79
+ def _get_file_info(path: Path, root: Path) -> FileInfo:
80
+ """Create FileInfo from a path."""
81
+ stat = path.stat()
82
+ return FileInfo(
83
+ name=path.name,
84
+ path=str(path.relative_to(root)),
85
+ is_directory=path.is_dir(),
86
+ size=stat.st_size if path.is_file() else None,
87
+ modified=stat.st_mtime,
88
+ )
89
+
90
+
91
+ @router.get("", response_model=FileListResponse)
92
+ async def list_files(
93
+ path: Annotated[str, Query(description="Relative path within workspace")] = "",
94
+ ) -> FileListResponse:
95
+ """List files and directories in the workspace.
96
+
97
+ Returns YAML files and directories. Hidden files are excluded.
98
+ """
99
+ root = get_workspace_root()
100
+ target = _resolve_path(path)
101
+
102
+ if not target.exists():
103
+ raise HTTPException(status_code=404, detail=f"Path not found: {path}")
104
+
105
+ if not target.is_dir():
106
+ raise HTTPException(status_code=400, detail=f"Not a directory: {path}")
107
+
108
+ files: list[FileInfo] = []
109
+ for item in sorted(target.iterdir()):
110
+ # Skip hidden files and common non-pipeline files
111
+ if item.name.startswith("."):
112
+ continue
113
+ if item.name in ("__pycache__", "node_modules", ".venv", "venv"):
114
+ continue
115
+
116
+ # Include directories and YAML files
117
+ if item.is_dir() or item.suffix in (".yaml", ".yml"):
118
+ files.append(_get_file_info(item, root))
119
+
120
+ return FileListResponse(root=str(root), files=files)
121
+
122
+
123
+ @router.get("/{file_path:path}", response_model=FileContent)
124
+ async def read_file(file_path: str) -> FileContent:
125
+ """Read a file's content.
126
+
127
+ Only YAML files can be read.
128
+ """
129
+ path = _resolve_path(file_path)
130
+
131
+ if not path.exists():
132
+ raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
133
+
134
+ if path.is_dir():
135
+ raise HTTPException(status_code=400, detail=f"Cannot read directory: {file_path}")
136
+
137
+ if path.suffix not in (".yaml", ".yml"):
138
+ raise HTTPException(status_code=400, detail=f"Only YAML files supported: {file_path}")
139
+
140
+ try:
141
+ content = path.read_text(encoding="utf-8")
142
+ stat = path.stat()
143
+ return FileContent(
144
+ path=file_path,
145
+ content=content,
146
+ modified=stat.st_mtime,
147
+ )
148
+ except PermissionError as e:
149
+ raise HTTPException(status_code=403, detail=f"Permission denied: {file_path}") from e
150
+ except Exception as e:
151
+ raise HTTPException(status_code=500, detail=f"Error reading file: {e}") from e
152
+
153
+
154
+ @router.put("/{file_path:path}", response_model=FileContent)
155
+ async def save_file(file_path: str, request: SaveRequest) -> FileContent:
156
+ """Save content to a file.
157
+
158
+ Creates parent directories if needed. Only YAML files can be saved.
159
+ """
160
+ path = _resolve_path(file_path)
161
+
162
+ if path.suffix not in (".yaml", ".yml"):
163
+ raise HTTPException(status_code=400, detail=f"Only YAML files supported: {file_path}")
164
+
165
+ try:
166
+ # Create parent directories if needed
167
+ path.parent.mkdir(parents=True, exist_ok=True)
168
+
169
+ # Write content
170
+ path.write_text(request.content, encoding="utf-8")
171
+
172
+ stat = path.stat()
173
+ return FileContent(
174
+ path=file_path,
175
+ content=request.content,
176
+ modified=stat.st_mtime,
177
+ )
178
+ except PermissionError as e:
179
+ raise HTTPException(status_code=403, detail=f"Permission denied: {file_path}") from e
180
+ except Exception as e:
181
+ raise HTTPException(status_code=500, detail=f"Error saving file: {e}") from e
182
+
183
+
184
+ @router.delete("/{file_path:path}")
185
+ async def delete_file(file_path: str) -> dict[str, str]:
186
+ """Delete a file.
187
+
188
+ Only YAML files can be deleted. Directories cannot be deleted.
189
+ """
190
+ path = _resolve_path(file_path)
191
+
192
+ if not path.exists():
193
+ raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
194
+
195
+ if path.is_dir():
196
+ raise HTTPException(status_code=400, detail="Cannot delete directories")
197
+
198
+ if path.suffix not in (".yaml", ".yml"):
199
+ raise HTTPException(status_code=400, detail=f"Only YAML files supported: {file_path}")
200
+
201
+ try:
202
+ path.unlink()
203
+ return {"status": "deleted", "path": file_path}
204
+ except PermissionError as e:
205
+ raise HTTPException(status_code=403, detail=f"Permission denied: {file_path}") from e
206
+ except Exception as e:
207
+ raise HTTPException(status_code=500, detail=f"Error deleting file: {e}") from e
@@ -0,0 +1,419 @@
1
+ """Plugin management API for hexdag studio.
2
+
3
+ Discovers and manages hexdag plugins for use in pipelines.
4
+ """
5
+
6
+ import importlib
7
+ import importlib.metadata
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fastapi import APIRouter
13
+ from pydantic import BaseModel
14
+
15
+ router = APIRouter(prefix="/plugins", tags=["plugins"])
16
+
17
+
18
+ class PluginInfo(BaseModel):
19
+ """Information about a plugin."""
20
+
21
+ name: str
22
+ version: str
23
+ description: str
24
+ module: str
25
+ adapters: list[dict[str, Any]]
26
+ nodes: list[dict[str, Any]]
27
+ installed: bool
28
+ enabled: bool
29
+
30
+
31
+ class PluginAdapter(BaseModel):
32
+ """Information about a plugin adapter."""
33
+
34
+ name: str
35
+ port_type: str
36
+ description: str
37
+ config_schema: dict[str, Any]
38
+ secrets: list[str]
39
+
40
+
41
+ class PluginNode(BaseModel):
42
+ """Information about a plugin node."""
43
+
44
+ kind: str
45
+ name: str
46
+ description: str
47
+ config_schema: dict[str, Any]
48
+ color: str
49
+
50
+
51
+ class PluginsResponse(BaseModel):
52
+ """List of available plugins."""
53
+
54
+ plugins: list[PluginInfo]
55
+
56
+
57
+ def discover_plugins() -> list[PluginInfo]:
58
+ """Discover installed hexdag plugins.
59
+
60
+ Looks for:
61
+ 1. Entry points with group 'hexdag.plugins'
62
+ 2. Packages matching 'hexdag-*' or 'hexdag_plugins.*'
63
+ 3. Local plugin directories in hexdag_plugins/
64
+ """
65
+ plugins = []
66
+
67
+ # 1. Check for local plugins in hexdag_plugins directory
68
+ plugins_dir = Path(__file__).parent.parent.parent.parent.parent / "hexdag_plugins"
69
+ if plugins_dir.exists():
70
+ for plugin_dir in plugins_dir.iterdir():
71
+ if plugin_dir.is_dir() and not plugin_dir.name.startswith("_"):
72
+ plugin_info = _load_local_plugin(plugin_dir)
73
+ if plugin_info:
74
+ plugins.append(plugin_info)
75
+
76
+ # 2. Check for installed packages via entry points
77
+ try:
78
+ eps = importlib.metadata.entry_points()
79
+ if hasattr(eps, "select"):
80
+ # Python 3.10+
81
+ hexdag_eps = eps.select(group="hexdag.plugins")
82
+ else:
83
+ # Python 3.9
84
+ hexdag_eps = eps.get("hexdag.plugins", [])
85
+
86
+ for ep in hexdag_eps:
87
+ try:
88
+ plugin_module = ep.load()
89
+ plugin_info = _create_plugin_info_from_module(ep.name, plugin_module)
90
+ # Avoid duplicates
91
+ if not any(p.name == plugin_info.name for p in plugins):
92
+ plugins.append(plugin_info)
93
+ except Exception as e:
94
+ print(f"Failed to load plugin {ep.name}: {e}")
95
+ except Exception:
96
+ pass
97
+
98
+ return plugins
99
+
100
+
101
+ def _load_local_plugin(plugin_dir: Path) -> PluginInfo | None:
102
+ """Load a plugin from a local directory."""
103
+ plugin_name = plugin_dir.name
104
+
105
+ # Check for __init__.py
106
+ init_file = plugin_dir / "__init__.py"
107
+ if not init_file.exists():
108
+ return None
109
+
110
+ # Try to import the module
111
+ try:
112
+ module_name = f"hexdag_plugins.{plugin_name}"
113
+
114
+ # Add parent to path if needed
115
+ parent_path = str(plugin_dir.parent)
116
+ if parent_path not in sys.path:
117
+ sys.path.insert(0, parent_path)
118
+
119
+ module = importlib.import_module(module_name)
120
+ return _create_plugin_info_from_module(plugin_name, module)
121
+
122
+ except Exception as e:
123
+ print(f"Failed to load local plugin {plugin_name}: {e}")
124
+ return None
125
+
126
+
127
+ def _create_plugin_info_from_module(name: str, module: Any) -> PluginInfo:
128
+ """Create PluginInfo from a loaded module."""
129
+ # Get version
130
+ version = getattr(module, "__version__", "0.0.0")
131
+
132
+ # Get description from docstring
133
+ description = (module.__doc__ or "").strip().split("\n")[0]
134
+
135
+ # Discover adapters
136
+ adapters = _discover_adapters(module)
137
+
138
+ # Discover nodes
139
+ nodes = _discover_nodes(module)
140
+
141
+ return PluginInfo(
142
+ name=name,
143
+ version=version,
144
+ description=description,
145
+ module=module.__name__,
146
+ adapters=adapters,
147
+ nodes=nodes,
148
+ installed=True,
149
+ enabled=True,
150
+ )
151
+
152
+
153
+ def _discover_adapters(module: Any) -> list[dict[str, Any]]:
154
+ """Discover adapter classes in a module."""
155
+ adapters = []
156
+
157
+ # Check __all__ for exported names
158
+ exported = getattr(module, "__all__", [])
159
+
160
+ for name in exported:
161
+ obj = getattr(module, name, None)
162
+ if obj is None:
163
+ continue
164
+
165
+ # Check if it's an adapter (using hexdag decorator attributes)
166
+ hexdag_type = getattr(obj, "_hexdag_type", None)
167
+ if (
168
+ hexdag_type is not None
169
+ and str(hexdag_type.value if hasattr(hexdag_type, "value") else hexdag_type)
170
+ == "adapter"
171
+ ):
172
+ port_type = getattr(obj, "_hexdag_implements_port", "unknown")
173
+ secrets_dict = getattr(obj, "_hexdag_secrets", {})
174
+ adapters.append({
175
+ "name": getattr(obj, "_hexdag_name", name),
176
+ "port_type": port_type,
177
+ "description": getattr(
178
+ obj, "_hexdag_description", (obj.__doc__ or "").strip().split("\n")[0]
179
+ ),
180
+ "config_schema": _extract_config_schema(obj),
181
+ "secrets": list(secrets_dict.keys()) if secrets_dict else [],
182
+ })
183
+ # Legacy check for _hexdag_adapter_metadata (backward compatibility)
184
+ elif metadata := getattr(obj, "_hexdag_adapter_metadata", None):
185
+ adapters.append({
186
+ "name": metadata.get("name", name),
187
+ "port_type": metadata.get("port_type", "unknown"),
188
+ "description": (obj.__doc__ or "").strip().split("\n")[0],
189
+ "config_schema": _extract_config_schema(obj),
190
+ "secrets": list(metadata.get("secrets", {}).keys()),
191
+ })
192
+ elif name.endswith("Adapter"):
193
+ # Fallback: treat classes ending in Adapter as adapters
194
+ port_type = _guess_port_type(name)
195
+ adapters.append({
196
+ "name": name,
197
+ "port_type": port_type,
198
+ "description": (obj.__doc__ or "").strip().split("\n")[0],
199
+ "config_schema": _extract_config_schema(obj),
200
+ "secrets": [],
201
+ })
202
+
203
+ return adapters
204
+
205
+
206
+ def _discover_nodes(module: Any) -> list[dict[str, Any]]:
207
+ """Discover node classes in a module."""
208
+ nodes = []
209
+
210
+ # Check __all__ for exported names
211
+ exported = getattr(module, "__all__", [])
212
+
213
+ for name in exported:
214
+ obj = getattr(module, name, None)
215
+ if obj is None:
216
+ continue
217
+
218
+ # Check if it's a node (using hexdag decorator attributes)
219
+ hexdag_type = getattr(obj, "_hexdag_type", None)
220
+ if (
221
+ hexdag_type is not None
222
+ and str(hexdag_type.value if hasattr(hexdag_type, "value") else hexdag_type) == "node"
223
+ ):
224
+ # Get namespace to construct full kind
225
+ namespace = getattr(obj, "_hexdag_namespace", "core")
226
+ node_name = getattr(obj, "_hexdag_name", _to_snake_case(name))
227
+ # Use namespace:name format for plugin nodes (not core)
228
+ kind = f"{namespace}:{node_name}" if namespace != "core" else node_name
229
+ nodes.append({
230
+ "kind": kind,
231
+ "name": name,
232
+ "namespace": namespace,
233
+ "description": getattr(
234
+ obj, "_hexdag_description", (obj.__doc__ or "").strip().split("\n")[0]
235
+ ),
236
+ "config_schema": _extract_config_schema(obj),
237
+ "color": "#6b7280", # Default color - can be extended later
238
+ })
239
+ # Legacy check for _hexdag_node_metadata (backward compatibility)
240
+ elif metadata := getattr(obj, "_hexdag_node_metadata", None):
241
+ nodes.append({
242
+ "kind": metadata.get("kind", name),
243
+ "name": metadata.get("name", name),
244
+ "description": (obj.__doc__ or "").strip().split("\n")[0],
245
+ "config_schema": _extract_config_schema(obj),
246
+ "color": metadata.get("color", "#6b7280"),
247
+ })
248
+ elif name.endswith("Node"):
249
+ # Fallback: treat classes ending in Node as nodes
250
+ nodes.append({
251
+ "kind": _to_snake_case(name),
252
+ "name": name,
253
+ "description": (obj.__doc__ or "").strip().split("\n")[0],
254
+ "config_schema": _extract_config_schema(obj),
255
+ "color": "#6b7280",
256
+ })
257
+
258
+ return nodes
259
+
260
+
261
+ def _extract_config_schema(cls: type) -> dict[str, Any]:
262
+ """Extract configuration schema from a class __init__ signature."""
263
+ import inspect
264
+
265
+ schema = {"type": "object", "properties": {}, "required": []}
266
+
267
+ try:
268
+ sig = inspect.signature(cls.__init__)
269
+ for param_name, param in sig.parameters.items():
270
+ if param_name in ("self", "args", "kwargs"):
271
+ continue
272
+
273
+ prop: dict[str, Any] = {}
274
+
275
+ # Get type annotation
276
+ if param.annotation != inspect.Parameter.empty:
277
+ prop["type"] = _python_type_to_json_type(param.annotation)
278
+
279
+ # Get default value
280
+ if param.default != inspect.Parameter.empty:
281
+ prop["default"] = param.default
282
+ else:
283
+ schema["required"].append(param_name)
284
+
285
+ schema["properties"][param_name] = prop
286
+
287
+ except Exception:
288
+ pass
289
+
290
+ return schema
291
+
292
+
293
+ def _python_type_to_json_type(py_type: Any) -> str:
294
+ """Convert Python type annotation to JSON schema type."""
295
+ type_str = str(py_type)
296
+
297
+ if "str" in type_str:
298
+ return "string"
299
+ if "int" in type_str:
300
+ return "integer"
301
+ if "float" in type_str:
302
+ return "number"
303
+ if "bool" in type_str:
304
+ return "boolean"
305
+ if "list" in type_str or "List" in type_str:
306
+ return "array"
307
+ if "dict" in type_str or "Dict" in type_str:
308
+ return "object"
309
+ return "string"
310
+
311
+
312
+ def _guess_port_type(adapter_name: str) -> str:
313
+ """Guess port type from adapter name."""
314
+ name_lower = adapter_name.lower()
315
+ if "openai" in name_lower or "llm" in name_lower or "anthropic" in name_lower:
316
+ return "llm"
317
+ if "memory" in name_lower or "cosmos" in name_lower:
318
+ return "memory"
319
+ if "storage" in name_lower or "blob" in name_lower or "s3" in name_lower:
320
+ return "storage"
321
+ if "keyvault" in name_lower or "secret" in name_lower:
322
+ return "secret"
323
+ if "database" in name_lower or "sql" in name_lower:
324
+ return "database"
325
+ return "unknown"
326
+
327
+
328
+ def _to_snake_case(name: str) -> str:
329
+ """Convert CamelCase to snake_case."""
330
+ import re
331
+
332
+ s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
333
+ return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
334
+
335
+
336
+ # ===== API Endpoints =====
337
+ # NOTE: Specific routes MUST come before parameterized routes to avoid matching issues
338
+
339
+
340
+ @router.get("", response_model=PluginsResponse)
341
+ async def list_plugins() -> PluginsResponse:
342
+ """List all available plugins."""
343
+ plugins = discover_plugins()
344
+ return PluginsResponse(plugins=plugins)
345
+
346
+
347
+ @router.get("/adapters/all")
348
+ async def get_all_adapters() -> list[dict[str, Any]]:
349
+ """Get all adapters from all plugins."""
350
+ plugins = discover_plugins()
351
+ adapters = []
352
+
353
+ for plugin in plugins:
354
+ for adapter in plugin.adapters:
355
+ adapter_copy = dict(adapter)
356
+ adapter_copy["plugin"] = plugin.name
357
+ adapters.append(adapter_copy)
358
+
359
+ return adapters
360
+
361
+
362
+ @router.get("/nodes/all")
363
+ async def get_all_nodes() -> list[dict[str, Any]]:
364
+ """Get all nodes from all plugins."""
365
+ plugins = discover_plugins()
366
+ nodes = []
367
+
368
+ for plugin in plugins:
369
+ for node in plugin.nodes:
370
+ node_copy = dict(node)
371
+ node_copy["plugin"] = plugin.name
372
+ nodes.append(node_copy)
373
+
374
+ return nodes
375
+
376
+
377
+ @router.get("/{plugin_name}")
378
+ async def get_plugin(plugin_name: str) -> PluginInfo:
379
+ """Get details about a specific plugin."""
380
+ plugins = discover_plugins()
381
+
382
+ for plugin in plugins:
383
+ if plugin.name == plugin_name:
384
+ return plugin
385
+
386
+ return PluginInfo(
387
+ name=plugin_name,
388
+ version="0.0.0",
389
+ description="Plugin not found",
390
+ module="",
391
+ adapters=[],
392
+ nodes=[],
393
+ installed=False,
394
+ enabled=False,
395
+ )
396
+
397
+
398
+ @router.get("/{plugin_name}/adapters")
399
+ async def get_plugin_adapters(plugin_name: str) -> list[dict[str, Any]]:
400
+ """Get adapters provided by a plugin."""
401
+ plugins = discover_plugins()
402
+
403
+ for plugin in plugins:
404
+ if plugin.name == plugin_name:
405
+ return plugin.adapters
406
+
407
+ return []
408
+
409
+
410
+ @router.get("/{plugin_name}/nodes")
411
+ async def get_plugin_nodes(plugin_name: str) -> list[dict[str, Any]]:
412
+ """Get nodes provided by a plugin."""
413
+ plugins = discover_plugins()
414
+
415
+ for plugin in plugins:
416
+ if plugin.name == plugin_name:
417
+ return plugin.nodes
418
+
419
+ return []