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.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- 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 []
|