hof-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. hof/__init__.py +35 -0
  2. hof/api/__init__.py +1 -0
  3. hof/api/auth.py +228 -0
  4. hof/api/routes/__init__.py +1 -0
  5. hof/api/routes/admin.py +89 -0
  6. hof/api/routes/flows.py +100 -0
  7. hof/api/routes/functions.py +98 -0
  8. hof/api/routes/tables.py +151 -0
  9. hof/api/routes/ws.py +77 -0
  10. hof/api/server.py +280 -0
  11. hof/app.py +180 -0
  12. hof/cli/__init__.py +1 -0
  13. hof/cli/api_client.py +86 -0
  14. hof/cli/commands/__init__.py +27 -0
  15. hof/cli/commands/add.py +456 -0
  16. hof/cli/commands/build.py +31 -0
  17. hof/cli/commands/cron_cmd.py +87 -0
  18. hof/cli/commands/db.py +119 -0
  19. hof/cli/commands/dev.py +287 -0
  20. hof/cli/commands/flow.py +171 -0
  21. hof/cli/commands/fn.py +136 -0
  22. hof/cli/commands/new.py +383 -0
  23. hof/cli/commands/table.py +140 -0
  24. hof/cli/main.py +44 -0
  25. hof/config.py +191 -0
  26. hof/core/__init__.py +1 -0
  27. hof/core/discovery.py +56 -0
  28. hof/core/registry.py +102 -0
  29. hof/core/types.py +45 -0
  30. hof/cron/__init__.py +1 -0
  31. hof/cron/scheduler.py +103 -0
  32. hof/db/__init__.py +1 -0
  33. hof/db/engine.py +148 -0
  34. hof/db/migrations.py +154 -0
  35. hof/db/schemas.py +114 -0
  36. hof/db/table.py +359 -0
  37. hof/errors.py +47 -0
  38. hof/files/__init__.py +1 -0
  39. hof/files/processors.py +59 -0
  40. hof/files/storage.py +50 -0
  41. hof/flows/__init__.py +1 -0
  42. hof/flows/executor.py +418 -0
  43. hof/flows/flow.py +211 -0
  44. hof/flows/human.py +60 -0
  45. hof/flows/models.py +61 -0
  46. hof/flows/node.py +140 -0
  47. hof/flows/state.py +297 -0
  48. hof/functions.py +166 -0
  49. hof/llm/__init__.py +6 -0
  50. hof/llm/decorators.py +57 -0
  51. hof/llm/provider.py +67 -0
  52. hof/logging_config.py +120 -0
  53. hof/scaffold.py +12 -0
  54. hof/tasks/__init__.py +1 -0
  55. hof/tasks/celery_app.py +137 -0
  56. hof/tasks/worker.py +48 -0
  57. hof/ui/__init__.py +1 -0
  58. hof/ui/admin/.gitignore +2 -0
  59. hof/ui/admin/dist/assets/index-C1dqIj3T.css +1 -0
  60. hof/ui/admin/dist/assets/index-D3orQUqG.js +82 -0
  61. hof/ui/admin/dist/index.html +13 -0
  62. hof/ui/admin/index.html +12 -0
  63. hof/ui/admin/package-lock.json +2108 -0
  64. hof/ui/admin/package.json +24 -0
  65. hof/ui/admin/src/App.tsx +156 -0
  66. hof/ui/admin/src/api.ts +162 -0
  67. hof/ui/admin/src/components/FlowGraph.tsx +116 -0
  68. hof/ui/admin/src/components/Sidebar.tsx +53 -0
  69. hof/ui/admin/src/components/UserComponent.tsx +74 -0
  70. hof/ui/admin/src/main.tsx +13 -0
  71. hof/ui/admin/src/pages/Dashboard.tsx +95 -0
  72. hof/ui/admin/src/pages/FlowList.tsx +54 -0
  73. hof/ui/admin/src/pages/FlowViewer.tsx +131 -0
  74. hof/ui/admin/src/pages/FunctionList.tsx +61 -0
  75. hof/ui/admin/src/pages/PendingActions.tsx +154 -0
  76. hof/ui/admin/src/pages/TableBrowser.tsx +102 -0
  77. hof/ui/admin/src/pages/TaskList.tsx +91 -0
  78. hof/ui/admin/src/styles.css +367 -0
  79. hof/ui/admin/tsconfig.json +17 -0
  80. hof/ui/admin/vite.config.ts +17 -0
  81. hof/ui/vite.py +461 -0
  82. hof-react/dist/index.cjs +231 -0
  83. hof-react/dist/index.d.cts +71 -0
  84. hof-react/dist/index.d.ts +71 -0
  85. hof-react/dist/index.js +201 -0
  86. hof-react/package.json +33 -0
  87. hof_engine-0.1.0.dist-info/METADATA +200 -0
  88. hof_engine-0.1.0.dist-info/RECORD +90 -0
  89. hof_engine-0.1.0.dist-info/WHEEL +4 -0
  90. hof_engine-0.1.0.dist-info/entry_points.txt +2 -0
hof/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """hof-engine: Full-stack Python + React framework."""
2
+
3
+ from hof.app import HofApp
4
+ from hof.config import Config
5
+ from hof.core.registry import registry
6
+ from hof.core.types import types
7
+ from hof.cron.scheduler import cron
8
+ from hof.db.table import Column, ForeignKey, Table
9
+ from hof.errors import HofError
10
+ from hof.flows.flow import Flow
11
+ from hof.flows.human import human_node
12
+ from hof.flows.node import node
13
+ from hof.functions import function
14
+ from hof.logging_config import configure_logging
15
+ from hof.scaffold import get_project_files
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "HofApp",
21
+ "Config",
22
+ "configure_logging",
23
+ "Table",
24
+ "Column",
25
+ "ForeignKey",
26
+ "Flow",
27
+ "node",
28
+ "human_node",
29
+ "function",
30
+ "cron",
31
+ "types",
32
+ "registry",
33
+ "HofError",
34
+ "get_project_files",
35
+ ]
hof/api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """API layer: FastAPI server and auto-generated routes."""
hof/api/auth.py ADDED
@@ -0,0 +1,228 @@
1
+ """Authentication and role-based access control for the hof API.
2
+
3
+ Supported authentication methods (tried in order):
4
+ 1. Bearer JWT token — ``Authorization: Bearer <token>``
5
+ 2. API key header — ``X-API-Key: <key>``
6
+ 3. HTTP Basic auth — username / password
7
+
8
+ JWT tokens are issued via ``POST /api/auth/token`` and carry the user's roles
9
+ as a claim. When no credentials are configured, all requests are allowed as
10
+ ``anonymous``.
11
+
12
+ Roles
13
+ -----
14
+ Roles are configured in ``hof.config.py``:
15
+
16
+ config = Config(
17
+ admin_username="admin",
18
+ admin_password="secret",
19
+ user_roles={
20
+ "admin": ["admin", "viewer"],
21
+ "readonly": ["viewer"],
22
+ },
23
+ )
24
+
25
+ Built-in roles:
26
+ - ``admin`` — full access to all endpoints
27
+ - ``viewer`` — read-only access (GET requests only)
28
+
29
+ Custom roles can be defined and checked with ``require_role()``.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import secrets
35
+ from datetime import UTC, datetime, timedelta
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ from fastapi import Depends, HTTPException, Request, Security, status
39
+ from fastapi.security import (
40
+ APIKeyHeader,
41
+ HTTPBasic,
42
+ HTTPBasicCredentials,
43
+ OAuth2PasswordBearer,
44
+ OAuth2PasswordRequestForm,
45
+ )
46
+
47
+ if TYPE_CHECKING:
48
+ from fastapi import FastAPI
49
+
50
+ from hof.config import Config
51
+
52
+ _config: Config | None = None
53
+
54
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
55
+ basic_auth = HTTPBasic(auto_error=False)
56
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Setup
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def setup_auth(app: FastAPI, config: Config) -> None:
65
+ """Configure authentication on the FastAPI app and register the token endpoint."""
66
+ global _config
67
+ _config = config
68
+
69
+ from fastapi import APIRouter
70
+
71
+ auth_router = APIRouter()
72
+
73
+ @auth_router.post("/token")
74
+ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
75
+ """Issue a JWT access token for valid credentials."""
76
+ cfg = _config
77
+ if cfg is None:
78
+ raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "Auth not configured")
79
+
80
+ username_ok = secrets.compare_digest(form_data.username, cfg.admin_username)
81
+ password_ok = secrets.compare_digest(form_data.password, cfg.admin_password)
82
+
83
+ if not (username_ok and password_ok):
84
+ raise HTTPException(
85
+ status.HTTP_401_UNAUTHORIZED,
86
+ detail="Incorrect username or password",
87
+ headers={"WWW-Authenticate": "Bearer"},
88
+ )
89
+
90
+ roles = cfg.user_roles.get(form_data.username, ["admin"])
91
+ token = _create_access_token(
92
+ subject=form_data.username,
93
+ roles=roles,
94
+ secret=cfg.jwt_secret_key or cfg.admin_password,
95
+ algorithm=cfg.jwt_algorithm,
96
+ expires_minutes=cfg.jwt_access_token_expire_minutes,
97
+ )
98
+ return {"access_token": token, "token_type": "bearer"}
99
+
100
+ app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # JWT helpers
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def _create_access_token(
109
+ *,
110
+ subject: str,
111
+ roles: list[str],
112
+ secret: str,
113
+ algorithm: str,
114
+ expires_minutes: int,
115
+ ) -> str:
116
+ try:
117
+ from jose import jwt
118
+ except ImportError:
119
+ raise ImportError("python-jose[cryptography] is required for JWT auth.")
120
+
121
+ expire = datetime.now(UTC) + timedelta(minutes=expires_minutes)
122
+ payload = {
123
+ "sub": subject,
124
+ "roles": roles,
125
+ "exp": expire,
126
+ }
127
+ return jwt.encode(payload, secret, algorithm=algorithm)
128
+
129
+
130
+ def _decode_jwt(token: str) -> dict[str, Any] | None:
131
+ if _config is None:
132
+ return None
133
+ secret = _config.jwt_secret_key or _config.admin_password
134
+ if not secret:
135
+ return None
136
+ try:
137
+ from jose import jwt
138
+
139
+ return jwt.decode(token, secret, algorithms=[_config.jwt_algorithm])
140
+ except Exception:
141
+ return None
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Dependency: verify authentication
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ async def verify_auth(
150
+ request: Request,
151
+ bearer_token: str | None = Depends(oauth2_scheme),
152
+ api_key: str | None = Security(api_key_header),
153
+ credentials: HTTPBasicCredentials | None = Depends(basic_auth),
154
+ ) -> str:
155
+ """Verify authentication via JWT Bearer token, API key, or Basic auth.
156
+
157
+ Returns the authenticated identity (username or ``"api-key"``).
158
+ When no credentials are configured, returns ``"anonymous"``.
159
+ """
160
+ if _config is None:
161
+ return "anonymous"
162
+
163
+ if not _config.admin_password and not _config.api_key and not _config.jwt_secret_key:
164
+ return "anonymous"
165
+
166
+ # 1. JWT Bearer token
167
+ if bearer_token:
168
+ payload = _decode_jwt(bearer_token)
169
+ if payload and "sub" in payload:
170
+ return payload["sub"]
171
+
172
+ # 2. API key
173
+ if api_key and _config.api_key:
174
+ if secrets.compare_digest(api_key, _config.api_key):
175
+ return "api-key"
176
+
177
+ # 3. HTTP Basic auth
178
+ if credentials and _config.admin_password:
179
+ username_ok = secrets.compare_digest(credentials.username, _config.admin_username)
180
+ password_ok = secrets.compare_digest(credentials.password, _config.admin_password)
181
+ if username_ok and password_ok:
182
+ return credentials.username
183
+
184
+ raise HTTPException(
185
+ status_code=status.HTTP_401_UNAUTHORIZED,
186
+ detail="Invalid or missing credentials",
187
+ headers={"WWW-Authenticate": 'Bearer, Basic realm="hof"'},
188
+ )
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Dependency: require specific role(s)
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ def require_role(*required_roles: str):
197
+ """FastAPI dependency factory that enforces role-based access.
198
+
199
+ Usage:
200
+ @router.delete("/{id}")
201
+ async def delete_item(user: str = Depends(require_role("admin"))):
202
+ ...
203
+ """
204
+
205
+ async def _check_role(user: str = Depends(verify_auth)) -> str:
206
+ if _config is None or not required_roles:
207
+ return user
208
+
209
+ # API key and anonymous get admin-level access when no RBAC is configured
210
+ if user in ("api-key", "anonymous"):
211
+ return user
212
+
213
+ user_role_list = _config.user_roles.get(user, [])
214
+ if not any(r in user_role_list for r in required_roles):
215
+ raise HTTPException(
216
+ status_code=status.HTTP_403_FORBIDDEN,
217
+ detail=f"Role(s) required: {', '.join(required_roles)}",
218
+ )
219
+ return user
220
+
221
+ return _check_role
222
+
223
+
224
+ def get_user_roles(username: str) -> list[str]:
225
+ """Return the roles assigned to a user."""
226
+ if _config is None:
227
+ return []
228
+ return _config.user_roles.get(username, [])
@@ -0,0 +1 @@
1
+ """API route modules."""
@@ -0,0 +1,89 @@
1
+ """Admin API routes for the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from hof.api.auth import verify_auth
8
+ from hof.core.registry import registry
9
+ from hof.flows.state import execution_store
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.get("/overview")
15
+ async def admin_overview(user: str = Depends(verify_auth)) -> dict:
16
+ """Dashboard overview: counts of all registered components and recent activity."""
17
+ recent_executions = execution_store.list_executions(limit=10)
18
+
19
+ return {
20
+ "registry": registry.summary(),
21
+ "tables": list(registry.tables.keys()),
22
+ "functions": list(registry.functions.keys()),
23
+ "flows": list(registry.flows.keys()),
24
+ "cron_jobs": list(registry.cron_jobs.keys()),
25
+ "recent_executions": [e.to_dict() for e in recent_executions],
26
+ }
27
+
28
+
29
+ @router.get("/flows/{flow_name}/dag")
30
+ async def flow_dag(
31
+ flow_name: str,
32
+ user: str = Depends(verify_auth),
33
+ ) -> dict:
34
+ """Get the DAG structure for rendering in the flow viewer."""
35
+ flow = registry.get_flow(flow_name)
36
+ if flow is None:
37
+ return {"error": f"Flow '{flow_name}' not found"}
38
+
39
+ nodes = []
40
+ edges = []
41
+
42
+ for name, meta in flow.nodes.items():
43
+ nodes.append(
44
+ {
45
+ "id": name,
46
+ "label": name,
47
+ "description": meta.fn.__doc__ or "",
48
+ "is_human": meta.is_human,
49
+ "human_ui": meta.human_ui,
50
+ "tags": meta.tags,
51
+ }
52
+ )
53
+ for dep in meta.depends_on:
54
+ edges.append({"source": dep, "target": name})
55
+
56
+ execution_order = flow.get_execution_order()
57
+
58
+ return {
59
+ "name": flow_name,
60
+ "nodes": nodes,
61
+ "edges": edges,
62
+ "execution_order": execution_order,
63
+ }
64
+
65
+
66
+ @router.get("/pending-actions")
67
+ async def pending_actions(user: str = Depends(verify_auth)) -> list[dict]:
68
+ """List all pending human-in-the-loop actions."""
69
+ executions = execution_store.list_executions(status="waiting_for_human", limit=50)
70
+ actions = []
71
+
72
+ for execution in executions:
73
+ for ns in execution.node_states:
74
+ if ns.status == "waiting_for_human":
75
+ flow = registry.get_flow(execution.flow_name)
76
+ node_meta = flow.nodes.get(ns.node_name) if flow else None
77
+
78
+ actions.append(
79
+ {
80
+ "execution_id": execution.id,
81
+ "flow_name": execution.flow_name,
82
+ "node_name": ns.node_name,
83
+ "ui_component": node_meta.human_ui if node_meta else None,
84
+ "input_data": ns.input_data,
85
+ "started_at": ns.started_at.isoformat() if ns.started_at else None,
86
+ }
87
+ )
88
+
89
+ return actions
@@ -0,0 +1,100 @@
1
+ """Flow management and execution routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException
8
+
9
+ from hof.api.auth import verify_auth
10
+ from hof.core.registry import registry
11
+ from hof.flows.state import execution_store
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.get("")
17
+ async def list_flows(user: str = Depends(verify_auth)) -> list[dict]:
18
+ """List all registered flow definitions."""
19
+ return [flow.to_dict() for flow in registry.flows.values()]
20
+
21
+
22
+ @router.post("/{flow_name}/run")
23
+ async def run_flow(
24
+ flow_name: str,
25
+ body: dict[str, Any] | None = None,
26
+ user: str = Depends(verify_auth),
27
+ ) -> dict:
28
+ """Trigger a new flow execution."""
29
+ flow = registry.get_flow(flow_name)
30
+ if flow is None:
31
+ raise HTTPException(404, f"Flow '{flow_name}' not found")
32
+
33
+ input_data = body or {}
34
+ execution = flow.run(**input_data)
35
+ return execution.to_dict()
36
+
37
+
38
+ @router.get("/{flow_name}/executions")
39
+ async def list_executions(
40
+ flow_name: str,
41
+ status: str | None = None,
42
+ limit: int = 20,
43
+ user: str = Depends(verify_auth),
44
+ ) -> list[dict]:
45
+ """List executions for a flow."""
46
+ flow = registry.get_flow(flow_name)
47
+ if flow is None:
48
+ raise HTTPException(404, f"Flow '{flow_name}' not found")
49
+
50
+ executions = execution_store.list_executions(flow_name=flow_name, status=status, limit=limit)
51
+ return [e.to_dict() for e in executions]
52
+
53
+
54
+ @router.get("/executions/{execution_id}")
55
+ async def get_execution(
56
+ execution_id: str,
57
+ user: str = Depends(verify_auth),
58
+ ) -> dict:
59
+ """Get details of a flow execution."""
60
+ execution = execution_store.get_execution(execution_id)
61
+ if execution is None:
62
+ raise HTTPException(404, f"Execution '{execution_id}' not found")
63
+ return execution.to_dict()
64
+
65
+
66
+ @router.post("/executions/{execution_id}/cancel")
67
+ async def cancel_execution(
68
+ execution_id: str,
69
+ user: str = Depends(verify_auth),
70
+ ) -> dict:
71
+ """Cancel a running execution."""
72
+ execution_store.update_status(execution_id, "cancelled")
73
+ return {"cancelled": True, "id": execution_id}
74
+
75
+
76
+ @router.post("/executions/{execution_id}/nodes/{node_name}/submit")
77
+ async def submit_human_input(
78
+ execution_id: str,
79
+ node_name: str,
80
+ body: dict[str, Any],
81
+ user: str = Depends(verify_auth),
82
+ ) -> dict:
83
+ """Submit human input for a waiting node."""
84
+ execution = execution_store.get_execution(execution_id)
85
+ if execution is None:
86
+ raise HTTPException(404, f"Execution '{execution_id}' not found")
87
+
88
+ flow = registry.get_flow(execution.flow_name)
89
+ if flow is None:
90
+ raise HTTPException(404, f"Flow '{execution.flow_name}' not found")
91
+
92
+ from hof.flows.executor import FlowExecutor
93
+
94
+ executor = FlowExecutor(flow)
95
+ result = executor.resume_after_human(execution_id, node_name, body)
96
+
97
+ if result is None:
98
+ raise HTTPException(400, "Could not submit input (node may not be waiting)")
99
+
100
+ return result.to_dict()
@@ -0,0 +1,98 @@
1
+ """Auto-generated routes for registered functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Request, Security
9
+ from fastapi.security import HTTPBasicCredentials
10
+ from pydantic import ValidationError
11
+
12
+ from hof.api.auth import api_key_header, basic_auth, oauth2_scheme, verify_auth
13
+ from hof.core.registry import registry
14
+ from hof.db.schemas import build_function_input_schema
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ async def _optional_auth(
20
+ request: Request,
21
+ bearer_token: str | None = Depends(oauth2_scheme),
22
+ api_key: str | None = Security(api_key_header),
23
+ credentials: HTTPBasicCredentials | None = Depends(basic_auth),
24
+ ) -> str:
25
+ """Resolve auth only when the target function is not public.
26
+
27
+ Public functions (``@function(public=True)``) skip authentication entirely.
28
+ """
29
+ function_name = request.path_params.get("function_name")
30
+ if function_name:
31
+ meta = registry.get_function(function_name)
32
+ if meta and meta.public:
33
+ return "anonymous"
34
+ return await verify_auth(
35
+ request,
36
+ bearer_token=bearer_token,
37
+ api_key=api_key,
38
+ credentials=credentials,
39
+ )
40
+
41
+
42
+ @router.get("")
43
+ async def list_functions(user: str = Depends(verify_auth)) -> list[dict]:
44
+ """List all registered functions."""
45
+ return [meta.to_dict() for meta in registry.functions.values()]
46
+
47
+
48
+ @router.post("/{function_name}")
49
+ async def call_function(
50
+ function_name: str,
51
+ body: dict[str, Any] | None = None,
52
+ user: str = Depends(_optional_auth),
53
+ ) -> dict:
54
+ """Execute a registered function. Input is validated against the function signature."""
55
+ meta = registry.get_function(function_name)
56
+ if meta is None:
57
+ raise HTTPException(404, f"Function '{function_name}' not found")
58
+
59
+ kwargs = body or {}
60
+
61
+ if meta.parameters:
62
+ schema = build_function_input_schema(meta)
63
+ try:
64
+ validated = schema(**kwargs)
65
+ except ValidationError as exc:
66
+ raise HTTPException(422, detail=exc.errors())
67
+ kwargs = validated.model_dump(exclude_none=False)
68
+
69
+ start = time.monotonic()
70
+
71
+ try:
72
+ if meta.is_async:
73
+ result = await meta.fn(**kwargs)
74
+ else:
75
+ result = meta.fn(**kwargs)
76
+ except Exception as exc:
77
+ raise HTTPException(500, f"Function error: {exc}")
78
+
79
+ duration_ms = int((time.monotonic() - start) * 1000)
80
+
81
+ return {
82
+ "result": result,
83
+ "duration_ms": duration_ms,
84
+ "function": function_name,
85
+ }
86
+
87
+
88
+ @router.get("/{function_name}/schema")
89
+ async def get_function_schema(
90
+ function_name: str,
91
+ user: str = Depends(verify_auth),
92
+ ) -> dict:
93
+ """Get the input/output schema of a function."""
94
+ meta = registry.get_function(function_name)
95
+ if meta is None:
96
+ raise HTTPException(404, f"Function '{function_name}' not found")
97
+
98
+ return meta.to_dict()