voodoo-framework 1.0.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.
- voodoo/__init__.py +51 -0
- voodoo/agent.py +37 -0
- voodoo/api.py +143 -0
- voodoo/cli.py +250 -0
- voodoo/components.py +107 -0
- voodoo/config.py +51 -0
- voodoo/core.py +236 -0
- voodoo/data.py +179 -0
- voodoo/i18n.py +107 -0
- voodoo/mcp.py +141 -0
- voodoo/queue.py +61 -0
- voodoo/status.py +131 -0
- voodoo/storage.py +85 -0
- voodoo/telemetry.py +157 -0
- voodoo/theme.py +112 -0
- voodoo_framework-1.0.0.dist-info/METADATA +23 -0
- voodoo_framework-1.0.0.dist-info/RECORD +20 -0
- voodoo_framework-1.0.0.dist-info/WHEEL +5 -0
- voodoo_framework-1.0.0.dist-info/entry_points.txt +2 -0
- voodoo_framework-1.0.0.dist-info/top_level.txt +1 -0
voodoo/core.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import importlib.util
|
|
5
|
+
from typing import Dict, Any, Callable
|
|
6
|
+
from starlette.applications import Starlette
|
|
7
|
+
from starlette.routing import Route, WebSocketRoute, Mount
|
|
8
|
+
from starlette.responses import HTMLResponse, Response
|
|
9
|
+
from starlette.websockets import WebSocket
|
|
10
|
+
from starlette.staticfiles import StaticFiles
|
|
11
|
+
import inspect
|
|
12
|
+
|
|
13
|
+
class WebSocketManager:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.active_connections: list[WebSocket] = []
|
|
16
|
+
|
|
17
|
+
async def connect(self, websocket: WebSocket):
|
|
18
|
+
await websocket.accept()
|
|
19
|
+
self.active_connections.append(websocket)
|
|
20
|
+
|
|
21
|
+
def disconnect(self, websocket: WebSocket):
|
|
22
|
+
if websocket in self.active_connections:
|
|
23
|
+
self.active_connections.remove(websocket)
|
|
24
|
+
|
|
25
|
+
async def broadcast_patch(self, element_id: str, html: str):
|
|
26
|
+
message = json.dumps({"type": "patch", "id": element_id, "html": html})
|
|
27
|
+
for connection in self.active_connections:
|
|
28
|
+
try:
|
|
29
|
+
await connection.send_text(message)
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
async def broadcast_append(self, element_id: str, html: str):
|
|
34
|
+
message = json.dumps({"type": "append", "id": element_id, "html": html})
|
|
35
|
+
for connection in self.active_connections:
|
|
36
|
+
try:
|
|
37
|
+
await connection.send_text(message)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
ws_manager = WebSocketManager()
|
|
42
|
+
event_handlers: Dict[str, Callable] = {}
|
|
43
|
+
|
|
44
|
+
def register_event(name: str, handler: Callable):
|
|
45
|
+
event_handlers[name] = handler
|
|
46
|
+
|
|
47
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
48
|
+
print(f"WS Connecting: {websocket.headers}")
|
|
49
|
+
await ws_manager.connect(websocket)
|
|
50
|
+
print("WS Connected!")
|
|
51
|
+
try:
|
|
52
|
+
while True:
|
|
53
|
+
data = await websocket.receive_text()
|
|
54
|
+
print(f"WS Received: {data}")
|
|
55
|
+
msg = json.loads(data)
|
|
56
|
+
if msg.get("type") == "event":
|
|
57
|
+
handler = event_handlers.get(msg["event"])
|
|
58
|
+
if handler:
|
|
59
|
+
if inspect.iscoroutinefunction(handler):
|
|
60
|
+
await handler(msg["id"], msg["value"])
|
|
61
|
+
else:
|
|
62
|
+
handler(msg["id"], msg["value"])
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(f"WS Disconnected/Error: {e}")
|
|
65
|
+
ws_manager.disconnect(websocket)
|
|
66
|
+
|
|
67
|
+
def render_page(component) -> str:
|
|
68
|
+
from voodoo.components import Component
|
|
69
|
+
from voodoo.theme import default_theme
|
|
70
|
+
|
|
71
|
+
html_content = component.render() if isinstance(component, Component) else str(component)
|
|
72
|
+
|
|
73
|
+
# Read client.js
|
|
74
|
+
client_js_path = os.path.join(os.path.dirname(__file__), "client.js")
|
|
75
|
+
with open(client_js_path, "r") as f:
|
|
76
|
+
client_js = f.read()
|
|
77
|
+
|
|
78
|
+
tailwind_config = default_theme.to_tailwind_config()
|
|
79
|
+
css_vars = default_theme.to_css_variables()
|
|
80
|
+
|
|
81
|
+
# Check for theme cookie if available via a simple hack or rely on client side script
|
|
82
|
+
# Better: handle it strictly on client side using JS on load to prevent flash of wrong theme
|
|
83
|
+
|
|
84
|
+
html_class = f"dark {default_theme.mode}" if default_theme.mode == "dark" else default_theme.mode
|
|
85
|
+
|
|
86
|
+
return f"""
|
|
87
|
+
<!DOCTYPE html>
|
|
88
|
+
<html lang="en" class="{html_class}">
|
|
89
|
+
<head>
|
|
90
|
+
<meta charset="UTF-8">
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
92
|
+
<title>Voodoo App</title>
|
|
93
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
94
|
+
<script>
|
|
95
|
+
tailwind.config = {tailwind_config};
|
|
96
|
+
|
|
97
|
+
// Prevent flash of incorrect theme
|
|
98
|
+
if (document.cookie.includes('voodoo_theme=light')) {{
|
|
99
|
+
document.documentElement.classList.remove('dark');
|
|
100
|
+
document.documentElement.classList.add('light');
|
|
101
|
+
}} else if (document.cookie.includes('voodoo_theme=dark')) {{
|
|
102
|
+
document.documentElement.classList.remove('light');
|
|
103
|
+
document.documentElement.classList.add('dark');
|
|
104
|
+
}}
|
|
105
|
+
</script>
|
|
106
|
+
<style>
|
|
107
|
+
{css_vars}
|
|
108
|
+
body {{
|
|
109
|
+
background-color: var(--color-background);
|
|
110
|
+
color: var(--color-text);
|
|
111
|
+
font-family: var(--font-sans);
|
|
112
|
+
}}
|
|
113
|
+
::-webkit-scrollbar {{ width: 8px; height: 8px; }}
|
|
114
|
+
::-webkit-scrollbar-track {{ background: transparent; }}
|
|
115
|
+
::-webkit-scrollbar-thumb {{ background: var(--color-surface); border-radius: 4px; border: 1px solid var(--color-border); }}
|
|
116
|
+
::-webkit-scrollbar-thumb:hover {{ background: var(--color-text-muted); }}
|
|
117
|
+
</style>
|
|
118
|
+
</head>
|
|
119
|
+
<body class="bg-[var(--color-background)] text-[var(--color-text)] min-h-screen antialiased selection:bg-[var(--color-secondary)] selection:text-white">
|
|
120
|
+
<div id="root">
|
|
121
|
+
{html_content}
|
|
122
|
+
</div>
|
|
123
|
+
<script>
|
|
124
|
+
{client_js}
|
|
125
|
+
</script>
|
|
126
|
+
</body>
|
|
127
|
+
</html>
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def create_app(app_dir: str = "app") -> Starlette:
|
|
131
|
+
from voodoo.config import config
|
|
132
|
+
base_storage_dir = os.path.join(os.getcwd(), config.storage_dir)
|
|
133
|
+
public_storage_dir = os.path.join(base_storage_dir, "public")
|
|
134
|
+
os.makedirs(public_storage_dir, exist_ok=True)
|
|
135
|
+
|
|
136
|
+
routes = [
|
|
137
|
+
WebSocketRoute("/_voodoo_ws", websocket_endpoint),
|
|
138
|
+
Mount("/storage/public", app=StaticFiles(directory=public_storage_dir), name="storage_public")
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
# Simple file-based router
|
|
142
|
+
pages_dir = os.path.join(app_dir, "pages")
|
|
143
|
+
if os.path.exists(pages_dir):
|
|
144
|
+
for filename in os.listdir(pages_dir):
|
|
145
|
+
if filename.endswith(".py") and not filename.startswith("_"):
|
|
146
|
+
name = filename[:-3]
|
|
147
|
+
route_path = "/" if name == "index" else f"/{name}"
|
|
148
|
+
filepath = os.path.join(pages_dir, filename)
|
|
149
|
+
spec = importlib.util.spec_from_file_location(f"page_{name}", filepath)
|
|
150
|
+
if spec and spec.loader:
|
|
151
|
+
page_module = importlib.util.module_from_spec(spec)
|
|
152
|
+
spec.loader.exec_module(page_module)
|
|
153
|
+
if hasattr(page_module, "page"):
|
|
154
|
+
# create a closure to capture the module
|
|
155
|
+
def make_route(mod):
|
|
156
|
+
async def handler(request):
|
|
157
|
+
sig = inspect.signature(mod.page)
|
|
158
|
+
if "request" in sig.parameters:
|
|
159
|
+
component = mod.page(request)
|
|
160
|
+
else:
|
|
161
|
+
component = mod.page()
|
|
162
|
+
if inspect.iscoroutine(component):
|
|
163
|
+
component = await component
|
|
164
|
+
return HTMLResponse(render_page(component))
|
|
165
|
+
return handler
|
|
166
|
+
routes.append(Route(route_path, make_route(page_module)))
|
|
167
|
+
else:
|
|
168
|
+
page_path = os.path.join(app_dir, "page.py")
|
|
169
|
+
if os.path.exists(page_path):
|
|
170
|
+
spec = importlib.util.spec_from_file_location("page", page_path)
|
|
171
|
+
if spec and spec.loader:
|
|
172
|
+
page_module = importlib.util.module_from_spec(spec)
|
|
173
|
+
spec.loader.exec_module(page_module)
|
|
174
|
+
|
|
175
|
+
if hasattr(page_module, "page"):
|
|
176
|
+
async def index(request):
|
|
177
|
+
sig = inspect.signature(page_module.page)
|
|
178
|
+
if "request" in sig.parameters:
|
|
179
|
+
component = page_module.page(request)
|
|
180
|
+
else:
|
|
181
|
+
component = page_module.page()
|
|
182
|
+
if inspect.iscoroutine(component):
|
|
183
|
+
component = await component
|
|
184
|
+
return HTMLResponse(render_page(component))
|
|
185
|
+
routes.append(Route("/", index))
|
|
186
|
+
|
|
187
|
+
# Initialize components if needed
|
|
188
|
+
models_path = os.path.join(app_dir, "models.py")
|
|
189
|
+
if os.path.exists(models_path):
|
|
190
|
+
spec = importlib.util.spec_from_file_location("models", models_path)
|
|
191
|
+
if spec and spec.loader:
|
|
192
|
+
models_module = importlib.util.module_from_spec(spec)
|
|
193
|
+
spec.loader.exec_module(models_module)
|
|
194
|
+
|
|
195
|
+
workers_path = os.path.join(app_dir, "workers.py")
|
|
196
|
+
if os.path.exists(workers_path):
|
|
197
|
+
spec = importlib.util.spec_from_file_location("workers", workers_path)
|
|
198
|
+
if spec and spec.loader:
|
|
199
|
+
workers_module = importlib.util.module_from_spec(spec)
|
|
200
|
+
spec.loader.exec_module(workers_module)
|
|
201
|
+
|
|
202
|
+
api_path = os.path.join(app_dir, "api.py")
|
|
203
|
+
if os.path.exists(api_path):
|
|
204
|
+
spec = importlib.util.spec_from_file_location("api", api_path)
|
|
205
|
+
if spec and spec.loader:
|
|
206
|
+
api_module = importlib.util.module_from_spec(spec)
|
|
207
|
+
spec.loader.exec_module(api_module)
|
|
208
|
+
|
|
209
|
+
from voodoo.api import api as voodoo_api
|
|
210
|
+
routes.extend(voodoo_api.routes)
|
|
211
|
+
|
|
212
|
+
from voodoo.queue import start_workers, stop_workers
|
|
213
|
+
from voodoo.data import init_db
|
|
214
|
+
from voodoo.telemetry import TelemetryMiddleware
|
|
215
|
+
from voodoo.i18n import I18nMiddleware
|
|
216
|
+
from starlette.middleware import Middleware
|
|
217
|
+
from contextlib import asynccontextmanager
|
|
218
|
+
|
|
219
|
+
@asynccontextmanager
|
|
220
|
+
async def lifespan(app: Starlette):
|
|
221
|
+
# Startup
|
|
222
|
+
await init_db()
|
|
223
|
+
worker_task = asyncio.create_task(start_workers())
|
|
224
|
+
yield
|
|
225
|
+
# Shutdown
|
|
226
|
+
await stop_workers()
|
|
227
|
+
worker_task.cancel()
|
|
228
|
+
|
|
229
|
+
middleware = [
|
|
230
|
+
Middleware(TelemetryMiddleware),
|
|
231
|
+
Middleware(I18nMiddleware)
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
app = Starlette(debug=True, routes=routes, middleware=middleware, lifespan=lifespan)
|
|
235
|
+
|
|
236
|
+
return app
|
voodoo/data.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import aiosqlite
|
|
3
|
+
from typing import Any, Callable, Dict, List, Type, get_type_hints
|
|
4
|
+
|
|
5
|
+
_db_connection = None
|
|
6
|
+
_triggers: Dict[str, Dict[str, List[Callable]]] = {}
|
|
7
|
+
_rls_policies: Dict[str, Callable] = {}
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
async def init_db(db_path: str = None):
|
|
12
|
+
if db_path is None:
|
|
13
|
+
from voodoo.config import config
|
|
14
|
+
db_path = config.db_path
|
|
15
|
+
|
|
16
|
+
db_dir = os.path.dirname(db_path)
|
|
17
|
+
if db_dir:
|
|
18
|
+
os.makedirs(db_dir, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
global _db_connection
|
|
21
|
+
_db_connection = await aiosqlite.connect(db_path)
|
|
22
|
+
_db_connection.row_factory = aiosqlite.Row
|
|
23
|
+
for model in _models:
|
|
24
|
+
await model._create_table()
|
|
25
|
+
|
|
26
|
+
async def get_db():
|
|
27
|
+
if _db_connection is None:
|
|
28
|
+
await init_db()
|
|
29
|
+
return _db_connection
|
|
30
|
+
|
|
31
|
+
def on_insert(model_cls: Type):
|
|
32
|
+
def decorator(func: Callable):
|
|
33
|
+
table = model_cls.__name__.lower()
|
|
34
|
+
if table not in _triggers:
|
|
35
|
+
_triggers[table] = {"insert": [], "update": []}
|
|
36
|
+
_triggers[table]["insert"].append(func)
|
|
37
|
+
return func
|
|
38
|
+
return decorator
|
|
39
|
+
|
|
40
|
+
def on_update(model_cls: Type):
|
|
41
|
+
def decorator(func: Callable):
|
|
42
|
+
table = model_cls.__name__.lower()
|
|
43
|
+
if table not in _triggers:
|
|
44
|
+
_triggers[table] = {"insert": [], "update": []}
|
|
45
|
+
_triggers[table]["update"].append(func)
|
|
46
|
+
return func
|
|
47
|
+
return decorator
|
|
48
|
+
|
|
49
|
+
def rls_policy(model_cls: Type):
|
|
50
|
+
def decorator(func: Callable):
|
|
51
|
+
table = model_cls.__name__.lower()
|
|
52
|
+
_rls_policies[table] = func
|
|
53
|
+
return func
|
|
54
|
+
return decorator
|
|
55
|
+
|
|
56
|
+
_models: List[Type] = []
|
|
57
|
+
|
|
58
|
+
class ModelMeta(type):
|
|
59
|
+
def __init__(cls, name, bases, attrs):
|
|
60
|
+
super().__init__(name, bases, attrs)
|
|
61
|
+
if name != "BaseModel":
|
|
62
|
+
_models.append(cls)
|
|
63
|
+
|
|
64
|
+
class BaseModel(metaclass=ModelMeta):
|
|
65
|
+
id: int
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
async def _create_table(cls):
|
|
69
|
+
db = await get_db()
|
|
70
|
+
table_name = cls.__name__.lower()
|
|
71
|
+
hints = get_type_hints(cls)
|
|
72
|
+
columns = []
|
|
73
|
+
for col_name, col_type in hints.items():
|
|
74
|
+
if col_name == "id":
|
|
75
|
+
columns.append("id INTEGER PRIMARY KEY AUTOINCREMENT")
|
|
76
|
+
elif col_type == int:
|
|
77
|
+
columns.append(f"{col_name} INTEGER")
|
|
78
|
+
elif col_type == str:
|
|
79
|
+
columns.append(f"{col_name} TEXT")
|
|
80
|
+
elif col_type == bool:
|
|
81
|
+
columns.append(f"{col_name} BOOLEAN")
|
|
82
|
+
else:
|
|
83
|
+
columns.append(f"{col_name} TEXT")
|
|
84
|
+
|
|
85
|
+
query = f"CREATE TABLE IF NOT EXISTS {table_name} ({', '.join(columns)})"
|
|
86
|
+
await db.execute(query)
|
|
87
|
+
await db.commit()
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
async def find_all(cls, user_context: dict = None) -> List['BaseModel']:
|
|
91
|
+
from voodoo.telemetry import telemetry_store
|
|
92
|
+
telemetry_store.record_db_query()
|
|
93
|
+
db = await get_db()
|
|
94
|
+
table_name = cls.__name__.lower()
|
|
95
|
+
query = f"SELECT * FROM {table_name}"
|
|
96
|
+
params = []
|
|
97
|
+
|
|
98
|
+
if table_name in _rls_policies and user_context:
|
|
99
|
+
policy = _rls_policies[table_name]
|
|
100
|
+
where_clause = policy(user_context)
|
|
101
|
+
if where_clause:
|
|
102
|
+
query += f" WHERE {where_clause}"
|
|
103
|
+
|
|
104
|
+
async with db.execute(query, params) as cursor:
|
|
105
|
+
rows = await cursor.fetchall()
|
|
106
|
+
results = []
|
|
107
|
+
hints = get_type_hints(cls)
|
|
108
|
+
for row in rows:
|
|
109
|
+
obj = cls()
|
|
110
|
+
for k in row.keys():
|
|
111
|
+
val = row[k]
|
|
112
|
+
if k in hints and hints[k] == bool:
|
|
113
|
+
val = bool(val)
|
|
114
|
+
setattr(obj, k, val)
|
|
115
|
+
results.append(obj)
|
|
116
|
+
return results
|
|
117
|
+
|
|
118
|
+
async def insert(self):
|
|
119
|
+
from voodoo.telemetry import telemetry_store
|
|
120
|
+
telemetry_store.record_db_query()
|
|
121
|
+
db = await get_db()
|
|
122
|
+
table_name = self.__class__.__name__.lower()
|
|
123
|
+
|
|
124
|
+
hints = get_type_hints(self.__class__)
|
|
125
|
+
cols = []
|
|
126
|
+
vals = []
|
|
127
|
+
placeholders = []
|
|
128
|
+
|
|
129
|
+
for col_name in hints.keys():
|
|
130
|
+
if col_name == "id" and not hasattr(self, "id"):
|
|
131
|
+
continue
|
|
132
|
+
if hasattr(self, col_name):
|
|
133
|
+
cols.append(col_name)
|
|
134
|
+
vals.append(getattr(self, col_name))
|
|
135
|
+
placeholders.append("?")
|
|
136
|
+
|
|
137
|
+
query = f"INSERT INTO {table_name} ({', '.join(cols)}) VALUES ({', '.join(placeholders)})"
|
|
138
|
+
cursor = await db.execute(query, vals)
|
|
139
|
+
await db.commit()
|
|
140
|
+
|
|
141
|
+
self.id = cursor.lastrowid
|
|
142
|
+
|
|
143
|
+
# Trigger hooks
|
|
144
|
+
if table_name in _triggers and _triggers[table_name]["insert"]:
|
|
145
|
+
for hook in _triggers[table_name]["insert"]:
|
|
146
|
+
if asyncio.iscoroutinefunction(hook):
|
|
147
|
+
asyncio.create_task(hook(self))
|
|
148
|
+
else:
|
|
149
|
+
hook(self)
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
async def update(self):
|
|
153
|
+
from voodoo.telemetry import telemetry_store
|
|
154
|
+
telemetry_store.record_db_query()
|
|
155
|
+
db = await get_db()
|
|
156
|
+
table_name = self.__class__.__name__.lower()
|
|
157
|
+
|
|
158
|
+
hints = get_type_hints(self.__class__)
|
|
159
|
+
cols = []
|
|
160
|
+
vals = []
|
|
161
|
+
|
|
162
|
+
for col_name in hints.keys():
|
|
163
|
+
if col_name != "id" and hasattr(self, col_name):
|
|
164
|
+
cols.append(f"{col_name} = ?")
|
|
165
|
+
vals.append(getattr(self, col_name))
|
|
166
|
+
|
|
167
|
+
vals.append(self.id)
|
|
168
|
+
query = f"UPDATE {table_name} SET {', '.join(cols)} WHERE id = ?"
|
|
169
|
+
await db.execute(query, vals)
|
|
170
|
+
await db.commit()
|
|
171
|
+
|
|
172
|
+
# Trigger hooks
|
|
173
|
+
if table_name in _triggers and _triggers[table_name]["update"]:
|
|
174
|
+
for hook in _triggers[table_name]["update"]:
|
|
175
|
+
if asyncio.iscoroutinefunction(hook):
|
|
176
|
+
asyncio.create_task(hook(self))
|
|
177
|
+
else:
|
|
178
|
+
hook(self)
|
|
179
|
+
return self
|
voodoo/i18n.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
|
|
8
|
+
# Context variables for the current request's language and translations
|
|
9
|
+
current_language: ContextVar[str] = ContextVar("current_language", default="en")
|
|
10
|
+
translations: ContextVar[Dict[str, Any]] = ContextVar("translations", default={})
|
|
11
|
+
|
|
12
|
+
class I18n:
|
|
13
|
+
"""Internationalization manager for Voodoo."""
|
|
14
|
+
def __init__(self, default_lang: str = "en", locales_dir: str = "locales"):
|
|
15
|
+
self.default_lang = default_lang
|
|
16
|
+
self.locales_dir = locales_dir
|
|
17
|
+
self.locales: Dict[str, Dict[str, Any]] = {}
|
|
18
|
+
self.load_locales()
|
|
19
|
+
|
|
20
|
+
def load_locales(self):
|
|
21
|
+
"""Loads JSON locale files from the locales directory."""
|
|
22
|
+
if not os.path.exists(self.locales_dir):
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
for filename in os.listdir(self.locales_dir):
|
|
26
|
+
if filename.endswith(".json"):
|
|
27
|
+
lang = filename[:-5]
|
|
28
|
+
filepath = os.path.join(self.locales_dir, filename)
|
|
29
|
+
try:
|
|
30
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
31
|
+
self.locales[lang] = json.load(f)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"Error loading locale {lang}: {e}")
|
|
34
|
+
|
|
35
|
+
def get_translation(self, lang: str, key: str, **kwargs) -> str:
|
|
36
|
+
"""Retrieves a translation string by key for a specific language."""
|
|
37
|
+
lang_dict = self.locales.get(lang) or self.locales.get(self.default_lang) or {}
|
|
38
|
+
|
|
39
|
+
# Support nested keys like "home.title"
|
|
40
|
+
keys = key.split(".")
|
|
41
|
+
val = lang_dict
|
|
42
|
+
for k in keys:
|
|
43
|
+
if isinstance(val, dict):
|
|
44
|
+
val = val.get(k)
|
|
45
|
+
else:
|
|
46
|
+
val = None
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if val is None:
|
|
50
|
+
return key # fallback to key if not found
|
|
51
|
+
|
|
52
|
+
if isinstance(val, str) and kwargs:
|
|
53
|
+
try:
|
|
54
|
+
return val.format(**kwargs)
|
|
55
|
+
except KeyError:
|
|
56
|
+
return val
|
|
57
|
+
|
|
58
|
+
return str(val)
|
|
59
|
+
|
|
60
|
+
# Global i18n instance
|
|
61
|
+
i18n_instance = I18n()
|
|
62
|
+
|
|
63
|
+
def _(key: str, **kwargs) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Translates a key using the current request's language.
|
|
66
|
+
This is the main function to be used in components and pages.
|
|
67
|
+
"""
|
|
68
|
+
lang = current_language.get()
|
|
69
|
+
return i18n_instance.get_translation(lang, key, **kwargs)
|
|
70
|
+
|
|
71
|
+
class I18nMiddleware(BaseHTTPMiddleware):
|
|
72
|
+
"""
|
|
73
|
+
Middleware to detect and set the language for the current request.
|
|
74
|
+
Priority: Query Param (?lang=) > Cookie (voodoo_lang) > Accept-Language Header > Default
|
|
75
|
+
"""
|
|
76
|
+
def __init__(self, app, i18n: Optional[I18n] = None):
|
|
77
|
+
super().__init__(app)
|
|
78
|
+
self.i18n = i18n or i18n_instance
|
|
79
|
+
|
|
80
|
+
async def dispatch(self, request: Request, call_next):
|
|
81
|
+
# 1. Query Param
|
|
82
|
+
lang = request.query_params.get("lang")
|
|
83
|
+
|
|
84
|
+
# 2. Cookie
|
|
85
|
+
if not lang:
|
|
86
|
+
lang = request.cookies.get("voodoo_lang")
|
|
87
|
+
|
|
88
|
+
# 3. Accept-Language Header
|
|
89
|
+
if not lang:
|
|
90
|
+
accept_lang = request.headers.get("accept-language")
|
|
91
|
+
if accept_lang:
|
|
92
|
+
# e.g., "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7"
|
|
93
|
+
lang = accept_lang.split(",")[0].split("-")[0]
|
|
94
|
+
|
|
95
|
+
if not lang or lang not in self.i18n.locales:
|
|
96
|
+
lang = self.i18n.default_lang
|
|
97
|
+
|
|
98
|
+
# Set the context variable for the duration of this request
|
|
99
|
+
token = current_language.set(lang)
|
|
100
|
+
try:
|
|
101
|
+
response = await call_next(request)
|
|
102
|
+
# If lang was set via query, we might want to set a cookie for future requests
|
|
103
|
+
if request.query_params.get("lang"):
|
|
104
|
+
response.set_cookie("voodoo_lang", lang)
|
|
105
|
+
return response
|
|
106
|
+
finally:
|
|
107
|
+
current_language.reset(token)
|
voodoo/mcp.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
from voodoo.api import api
|
|
5
|
+
from starlette.requests import Request
|
|
6
|
+
from starlette.responses import JSONResponse, StreamingResponse
|
|
7
|
+
|
|
8
|
+
class MCPServer:
|
|
9
|
+
def __init__(self, name: str = "voodoo-mcp", version: str = "1.0.0"):
|
|
10
|
+
self.name = name
|
|
11
|
+
self.version = version
|
|
12
|
+
self.tools: Dict[str, Dict[str, Any]] = {}
|
|
13
|
+
self.resources: Dict[str, Dict[str, Any]] = {}
|
|
14
|
+
|
|
15
|
+
# Register SSE endpoints
|
|
16
|
+
api.get("/mcp/sse")(self._sse_endpoint)
|
|
17
|
+
api.post("/mcp/messages")(self._messages_endpoint)
|
|
18
|
+
|
|
19
|
+
def tool(self, name: Optional[str] = None, description: Optional[str] = None):
|
|
20
|
+
def decorator(func: Callable):
|
|
21
|
+
tool_name = name or func.__name__
|
|
22
|
+
self.tools[tool_name] = {
|
|
23
|
+
"func": func,
|
|
24
|
+
"description": description or func.__doc__ or "No description provided."
|
|
25
|
+
}
|
|
26
|
+
return func
|
|
27
|
+
return decorator
|
|
28
|
+
|
|
29
|
+
def resource(self, uri: str, name: Optional[str] = None):
|
|
30
|
+
def decorator(func: Callable):
|
|
31
|
+
res_name = name or func.__name__
|
|
32
|
+
self.resources[uri] = {
|
|
33
|
+
"func": func,
|
|
34
|
+
"name": res_name
|
|
35
|
+
}
|
|
36
|
+
return func
|
|
37
|
+
return decorator
|
|
38
|
+
|
|
39
|
+
async def _sse_endpoint(self, request: Request):
|
|
40
|
+
async def event_generator():
|
|
41
|
+
yield 'event: endpoint\ndata: /mcp/messages\n\n'
|
|
42
|
+
while True:
|
|
43
|
+
await asyncio.sleep(15)
|
|
44
|
+
yield ':\n\n'
|
|
45
|
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
46
|
+
|
|
47
|
+
async def _messages_endpoint(self, request: Request):
|
|
48
|
+
try:
|
|
49
|
+
body = await request.json()
|
|
50
|
+
method = body.get("method")
|
|
51
|
+
params = body.get("params", {})
|
|
52
|
+
msg_id = body.get("id")
|
|
53
|
+
|
|
54
|
+
if method == "tools/list":
|
|
55
|
+
tools_list = [
|
|
56
|
+
{
|
|
57
|
+
"name": t_name,
|
|
58
|
+
"description": t_data["description"],
|
|
59
|
+
"inputSchema": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for t_name, t_data in self.tools.items()
|
|
65
|
+
]
|
|
66
|
+
return JSONResponse({
|
|
67
|
+
"jsonrpc": "2.0",
|
|
68
|
+
"id": msg_id,
|
|
69
|
+
"result": {"tools": tools_list}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
elif method == "tools/call":
|
|
73
|
+
tool_name = params.get("name")
|
|
74
|
+
args = params.get("arguments", {})
|
|
75
|
+
if tool_name in self.tools:
|
|
76
|
+
func = self.tools[tool_name]["func"]
|
|
77
|
+
if inspect.iscoroutinefunction(func):
|
|
78
|
+
result = await func(**args)
|
|
79
|
+
else:
|
|
80
|
+
result = func(**args)
|
|
81
|
+
return JSONResponse({
|
|
82
|
+
"jsonrpc": "2.0",
|
|
83
|
+
"id": msg_id,
|
|
84
|
+
"result": {"content": [{"type": "text", "text": str(result)}]}
|
|
85
|
+
})
|
|
86
|
+
else:
|
|
87
|
+
return JSONResponse({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "Tool not found"}})
|
|
88
|
+
|
|
89
|
+
elif method == "resources/list":
|
|
90
|
+
res_list = [
|
|
91
|
+
{
|
|
92
|
+
"uri": uri,
|
|
93
|
+
"name": r_data["name"]
|
|
94
|
+
}
|
|
95
|
+
for uri, r_data in self.resources.items()
|
|
96
|
+
]
|
|
97
|
+
return JSONResponse({
|
|
98
|
+
"jsonrpc": "2.0",
|
|
99
|
+
"id": msg_id,
|
|
100
|
+
"result": {"resources": res_list}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
elif method == "resources/read":
|
|
104
|
+
uri = params.get("uri")
|
|
105
|
+
if uri in self.resources:
|
|
106
|
+
func = self.resources[uri]["func"]
|
|
107
|
+
if inspect.iscoroutinefunction(func):
|
|
108
|
+
content = await func()
|
|
109
|
+
else:
|
|
110
|
+
content = func()
|
|
111
|
+
return JSONResponse({
|
|
112
|
+
"jsonrpc": "2.0",
|
|
113
|
+
"id": msg_id,
|
|
114
|
+
"result": {"contents": [{"uri": uri, "text": str(content)}]}
|
|
115
|
+
})
|
|
116
|
+
return JSONResponse({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32602, "message": "Resource not found"}})
|
|
117
|
+
|
|
118
|
+
return JSONResponse({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "Method not found"}})
|
|
119
|
+
except Exception as e:
|
|
120
|
+
return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}}, status_code=500)
|
|
121
|
+
|
|
122
|
+
class MCPClient:
|
|
123
|
+
def __init__(self, endpoint_url: str):
|
|
124
|
+
self.endpoint_url = endpoint_url
|
|
125
|
+
|
|
126
|
+
async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any:
|
|
127
|
+
import httpx
|
|
128
|
+
async with httpx.AsyncClient() as client:
|
|
129
|
+
payload = {
|
|
130
|
+
"jsonrpc": "2.0",
|
|
131
|
+
"method": "tools/call",
|
|
132
|
+
"params": {
|
|
133
|
+
"name": name,
|
|
134
|
+
"arguments": arguments
|
|
135
|
+
},
|
|
136
|
+
"id": 1
|
|
137
|
+
}
|
|
138
|
+
response = await client.post(self.endpoint_url, json=payload)
|
|
139
|
+
return response.json()
|
|
140
|
+
|
|
141
|
+
mcp = MCPServer()
|