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/queue.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Callable, Dict, Any
|
|
5
|
+
|
|
6
|
+
_queues: Dict[str, asyncio.Queue] = {}
|
|
7
|
+
_workers: Dict[str, Callable] = {}
|
|
8
|
+
_worker_tasks: list[asyncio.Task] = []
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("voodoo.queue")
|
|
11
|
+
|
|
12
|
+
def queue(name: str):
|
|
13
|
+
def decorator(func: Callable):
|
|
14
|
+
if name not in _queues:
|
|
15
|
+
_queues[name] = asyncio.Queue()
|
|
16
|
+
_workers[name] = func
|
|
17
|
+
return func
|
|
18
|
+
return decorator
|
|
19
|
+
|
|
20
|
+
async def enqueue(name: str, payload: Any):
|
|
21
|
+
if name not in _queues:
|
|
22
|
+
_queues[name] = asyncio.Queue()
|
|
23
|
+
from voodoo.telemetry import trace_id_var
|
|
24
|
+
trace_id = trace_id_var.get()
|
|
25
|
+
await _queues[name].put({"payload": payload, "trace_id": trace_id})
|
|
26
|
+
|
|
27
|
+
async def _run_worker(name: str):
|
|
28
|
+
q = _queues[name]
|
|
29
|
+
func = _workers[name]
|
|
30
|
+
from voodoo.telemetry import trace_id_var
|
|
31
|
+
import uuid
|
|
32
|
+
while True:
|
|
33
|
+
try:
|
|
34
|
+
item = await q.get()
|
|
35
|
+
payload = item.get("payload")
|
|
36
|
+
trace_id = item.get("trace_id") or str(uuid.uuid4())
|
|
37
|
+
token = trace_id_var.set(trace_id)
|
|
38
|
+
try:
|
|
39
|
+
if inspect.iscoroutinefunction(func):
|
|
40
|
+
await func(payload)
|
|
41
|
+
else:
|
|
42
|
+
func(payload)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error(f"Error in worker {name}: {e}")
|
|
45
|
+
finally:
|
|
46
|
+
trace_id_var.reset(token)
|
|
47
|
+
q.task_done()
|
|
48
|
+
except asyncio.CancelledError:
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
async def start_workers():
|
|
52
|
+
for name in _workers.keys():
|
|
53
|
+
task = asyncio.create_task(_run_worker(name))
|
|
54
|
+
_worker_tasks.append(task)
|
|
55
|
+
|
|
56
|
+
async def stop_workers():
|
|
57
|
+
for task in _worker_tasks:
|
|
58
|
+
task.cancel()
|
|
59
|
+
if _worker_tasks:
|
|
60
|
+
await asyncio.gather(*_worker_tasks, return_exceptions=True)
|
|
61
|
+
_worker_tasks.clear()
|
voodoo/status.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
from voodoo.api import api
|
|
4
|
+
from voodoo.data import get_db
|
|
5
|
+
from voodoo.queue import _worker_tasks, _queues
|
|
6
|
+
from voodoo.storage import storage
|
|
7
|
+
from voodoo.components import Div, Card, Heading
|
|
8
|
+
|
|
9
|
+
from voodoo.i18n import _
|
|
10
|
+
|
|
11
|
+
async def check_database() -> Dict[str, Any]:
|
|
12
|
+
try:
|
|
13
|
+
db = await get_db()
|
|
14
|
+
await db.execute("SELECT 1")
|
|
15
|
+
return {"status": "Operational", "latency": "ok"}
|
|
16
|
+
except Exception as e:
|
|
17
|
+
return {"status": "Down", "error": str(e)}
|
|
18
|
+
|
|
19
|
+
async def check_queue() -> Dict[str, Any]:
|
|
20
|
+
try:
|
|
21
|
+
total_workers = len(_worker_tasks)
|
|
22
|
+
active_queues = len(_queues)
|
|
23
|
+
return {
|
|
24
|
+
"status": "Operational" if total_workers > 0 else "Degraded",
|
|
25
|
+
"workers": total_workers,
|
|
26
|
+
"queues": active_queues
|
|
27
|
+
}
|
|
28
|
+
except Exception as e:
|
|
29
|
+
return {"status": "Down", "error": str(e)}
|
|
30
|
+
|
|
31
|
+
async def check_storage() -> Dict[str, Any]:
|
|
32
|
+
try:
|
|
33
|
+
if storage.use_s3 and storage.s3_client:
|
|
34
|
+
return {"status": "Operational", "type": "S3"}
|
|
35
|
+
else:
|
|
36
|
+
import os
|
|
37
|
+
if os.path.exists(storage.base_dir):
|
|
38
|
+
return {"status": "Operational", "type": "Local"}
|
|
39
|
+
return {"status": "Degraded", "type": "Local", "error": "Dir missing"}
|
|
40
|
+
except Exception as e:
|
|
41
|
+
return {"status": "Down", "error": str(e)}
|
|
42
|
+
|
|
43
|
+
async def check_agent() -> Dict[str, Any]:
|
|
44
|
+
# Placeholder for Agent connectivity
|
|
45
|
+
return {"status": "Operational", "provider": "Simulated"}
|
|
46
|
+
|
|
47
|
+
@api.get("/status")
|
|
48
|
+
async def get_status():
|
|
49
|
+
db_health = await check_database()
|
|
50
|
+
queue_health = await check_queue()
|
|
51
|
+
storage_health = await check_storage()
|
|
52
|
+
agent_health = await check_agent()
|
|
53
|
+
|
|
54
|
+
status_code = 200
|
|
55
|
+
if any(s["status"] == "Down" for s in [db_health, queue_health, storage_health, agent_health]):
|
|
56
|
+
status_code = 503
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"services": {
|
|
60
|
+
"database": db_health,
|
|
61
|
+
"queue": queue_health,
|
|
62
|
+
"storage": storage_health,
|
|
63
|
+
"agent": agent_health
|
|
64
|
+
},
|
|
65
|
+
"overall": "Operational" if status_code == 200 else "Down"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@api.get("/status/locales")
|
|
69
|
+
async def get_status_locales():
|
|
70
|
+
return {
|
|
71
|
+
"database": _("status.database"),
|
|
72
|
+
"queue": _("status.queue"),
|
|
73
|
+
"storage": _("status.storage"),
|
|
74
|
+
"agent": _("status.agent"),
|
|
75
|
+
"Operational": _("status.operational"),
|
|
76
|
+
"Degraded": _("status.degraded"),
|
|
77
|
+
"Down": _("status.down")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class ServiceStatus(Div):
|
|
81
|
+
def __init__(self, **kwargs):
|
|
82
|
+
# Provide default glass aesthetics for the card
|
|
83
|
+
classes = kwargs.pop("className", "")
|
|
84
|
+
card_classes = f"bg-[var(--color-surface)] border-[var(--color-border)] backdrop-blur-xl {classes}".strip()
|
|
85
|
+
super().__init__(
|
|
86
|
+
Card(
|
|
87
|
+
Heading(_("status.title"), level=2, className="text-xl font-semibold mb-6 text-[var(--color-text)] tracking-tight"),
|
|
88
|
+
Div(id="service-status-container", className="space-y-3"),
|
|
89
|
+
Div(
|
|
90
|
+
"""
|
|
91
|
+
<script>
|
|
92
|
+
async function fetchStatus() {
|
|
93
|
+
try {
|
|
94
|
+
const [resStatus, resLocales] = await Promise.all([
|
|
95
|
+
fetch('/status'),
|
|
96
|
+
fetch('/status/locales')
|
|
97
|
+
]);
|
|
98
|
+
const data = await resStatus.json();
|
|
99
|
+
const locales = await resLocales.json();
|
|
100
|
+
|
|
101
|
+
const container = document.getElementById('service-status-container');
|
|
102
|
+
let html = '';
|
|
103
|
+
for (const [service, info] of Object.entries(data.services)) {
|
|
104
|
+
let color = info.status === 'Operational' ? 'text-green-400' : (info.status === 'Degraded' ? 'text-yellow-400' : 'text-red-400');
|
|
105
|
+
let dot = info.status === 'Operational' ? 'bg-green-400' : (info.status === 'Degraded' ? 'bg-yellow-400' : 'bg-red-400');
|
|
106
|
+
let serviceName = locales[service] || service;
|
|
107
|
+
let statusName = locales[info.status] || info.status;
|
|
108
|
+
html += `
|
|
109
|
+
<div class="flex items-center justify-between p-3 bg-[var(--color-surface)] rounded-lg border border-[var(--color-border)] backdrop-blur-sm">
|
|
110
|
+
<div class="flex items-center space-x-3">
|
|
111
|
+
<div class="w-2.5 h-2.5 rounded-full ${dot} shadow-[0_0_8px_rgba(0,0,0,0.5)] shadow-${dot.replace('bg-', '')}"></div>
|
|
112
|
+
<span class="capitalize text-[var(--color-text)] font-medium tracking-wide">${serviceName}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<span class="${color} font-semibold text-sm tracking-wide">${statusName}</span>
|
|
115
|
+
</div>
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
container.innerHTML = html;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error('Failed to fetch status', e);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
fetchStatus();
|
|
124
|
+
setInterval(fetchStatus, 10000);
|
|
125
|
+
</script>
|
|
126
|
+
"""
|
|
127
|
+
),
|
|
128
|
+
className=card_classes,
|
|
129
|
+
**kwargs
|
|
130
|
+
)
|
|
131
|
+
)
|
voodoo/storage.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import aiofiles
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Union
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import boto3
|
|
8
|
+
import botocore.config
|
|
9
|
+
except ImportError:
|
|
10
|
+
boto3 = None
|
|
11
|
+
|
|
12
|
+
class StorageManager:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.s3_bucket = os.getenv("VOODOO_S3_BUCKET")
|
|
15
|
+
self.key = os.getenv("VOODOO_S3_KEY")
|
|
16
|
+
self.secret = os.getenv("VOODOO_S3_SECRET")
|
|
17
|
+
self.endpoint = os.getenv("VOODOO_S3_ENDPOINT")
|
|
18
|
+
|
|
19
|
+
self.use_s3 = all([self.s3_bucket, self.key, self.secret, self.endpoint])
|
|
20
|
+
self.base_dir = os.path.join(os.getcwd(), os.getenv("VOODOO_STORAGE_DIR", "storage"))
|
|
21
|
+
|
|
22
|
+
if self.use_s3 and boto3:
|
|
23
|
+
self.s3_client = boto3.client(
|
|
24
|
+
's3',
|
|
25
|
+
aws_access_key_id=self.key,
|
|
26
|
+
aws_secret_access_key=self.secret,
|
|
27
|
+
endpoint_url=self.endpoint,
|
|
28
|
+
config=botocore.config.Config(signature_version='s3v4')
|
|
29
|
+
)
|
|
30
|
+
else:
|
|
31
|
+
self.s3_client = None
|
|
32
|
+
|
|
33
|
+
def _get_local_path(self, bucket: str, path: str) -> str:
|
|
34
|
+
"""Helper to resolve the local file path for a specific bucket."""
|
|
35
|
+
return os.path.join(self.base_dir, bucket, path)
|
|
36
|
+
|
|
37
|
+
async def upload(self, file_content: Union[bytes, str], path: str, bucket: str = "public") -> str:
|
|
38
|
+
"""Uploads a file to a specific bucket and returns its path/url"""
|
|
39
|
+
if isinstance(file_content, str):
|
|
40
|
+
file_content = file_content.encode('utf-8')
|
|
41
|
+
|
|
42
|
+
if self.use_s3 and self.s3_client:
|
|
43
|
+
s3_key = f"{bucket}/{path}"
|
|
44
|
+
await asyncio.to_thread(
|
|
45
|
+
self.s3_client.put_object,
|
|
46
|
+
Bucket=self.s3_bucket,
|
|
47
|
+
Key=s3_key,
|
|
48
|
+
Body=file_content
|
|
49
|
+
)
|
|
50
|
+
return self.url(path, bucket)
|
|
51
|
+
else:
|
|
52
|
+
local_path = self._get_local_path(bucket, path)
|
|
53
|
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
|
54
|
+
async with aiofiles.open(local_path, 'wb') as f:
|
|
55
|
+
await f.write(file_content)
|
|
56
|
+
return self.url(path, bucket)
|
|
57
|
+
|
|
58
|
+
async def delete(self, path: str, bucket: str = "public") -> bool:
|
|
59
|
+
"""Deletes a file from a specific bucket"""
|
|
60
|
+
if self.use_s3 and self.s3_client:
|
|
61
|
+
s3_key = f"{bucket}/{path}"
|
|
62
|
+
await asyncio.to_thread(
|
|
63
|
+
self.s3_client.delete_object,
|
|
64
|
+
Bucket=self.s3_bucket,
|
|
65
|
+
Key=s3_key
|
|
66
|
+
)
|
|
67
|
+
return True
|
|
68
|
+
else:
|
|
69
|
+
local_path = self._get_local_path(bucket, path)
|
|
70
|
+
if os.path.exists(local_path):
|
|
71
|
+
os.remove(local_path)
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def url(self, path: str, bucket: str = "public") -> str:
|
|
76
|
+
"""Returns the URL for a file in a specific bucket"""
|
|
77
|
+
if self.use_s3 and self.s3_client:
|
|
78
|
+
s3_key = f"{bucket}/{path}"
|
|
79
|
+
if "amazonaws.com" in self.endpoint:
|
|
80
|
+
return f"https://{self.s3_bucket}.s3.amazonaws.com/{s3_key}"
|
|
81
|
+
return f"{self.endpoint}/{self.s3_bucket}/{s3_key}"
|
|
82
|
+
else:
|
|
83
|
+
return f"/storage/{bucket}/{path}"
|
|
84
|
+
|
|
85
|
+
storage = StorageManager()
|
voodoo/telemetry.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
import logging
|
|
4
|
+
import contextvars
|
|
5
|
+
import functools
|
|
6
|
+
import traceback
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Any, Callable, Dict, List
|
|
9
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
10
|
+
from starlette.responses import JSONResponse
|
|
11
|
+
from voodoo.api import api
|
|
12
|
+
|
|
13
|
+
# Context variable for tracing
|
|
14
|
+
trace_id_var = contextvars.ContextVar("trace_id", default=None)
|
|
15
|
+
|
|
16
|
+
class TelemetryStore:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.metrics: Dict[str, Any] = {
|
|
19
|
+
"requests_total": 0,
|
|
20
|
+
"errors_total": 0,
|
|
21
|
+
"latencies_ms": [],
|
|
22
|
+
"custom_traces": [],
|
|
23
|
+
"db_queries": 0,
|
|
24
|
+
"agent_tokens": 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def record_request(self, latency_ms: float, error: bool = False):
|
|
28
|
+
self.metrics["requests_total"] += 1
|
|
29
|
+
if error:
|
|
30
|
+
self.metrics["errors_total"] += 1
|
|
31
|
+
self.metrics["latencies_ms"].append(latency_ms)
|
|
32
|
+
if len(self.metrics["latencies_ms"]) > 1000:
|
|
33
|
+
self.metrics["latencies_ms"].pop(0)
|
|
34
|
+
|
|
35
|
+
def record_trace(self, name: str, latency_ms: float, error: bool = False):
|
|
36
|
+
self.metrics["custom_traces"].append({
|
|
37
|
+
"name": name,
|
|
38
|
+
"latency_ms": latency_ms,
|
|
39
|
+
"error": error,
|
|
40
|
+
"timestamp": time.time(),
|
|
41
|
+
"trace_id": trace_id_var.get()
|
|
42
|
+
})
|
|
43
|
+
if len(self.metrics["custom_traces"]) > 1000:
|
|
44
|
+
self.metrics["custom_traces"].pop(0)
|
|
45
|
+
|
|
46
|
+
def record_db_query(self):
|
|
47
|
+
self.metrics["db_queries"] += 1
|
|
48
|
+
|
|
49
|
+
def record_agent_tokens(self, count: int):
|
|
50
|
+
self.metrics["agent_tokens"] += count
|
|
51
|
+
|
|
52
|
+
def get_summary(self) -> Dict[str, Any]:
|
|
53
|
+
latencies = self.metrics["latencies_ms"]
|
|
54
|
+
avg_latency = sum(latencies) / len(latencies) if latencies else 0.0
|
|
55
|
+
return {
|
|
56
|
+
"requests_total": self.metrics["requests_total"],
|
|
57
|
+
"errors_total": self.metrics["errors_total"],
|
|
58
|
+
"average_latency_ms": round(avg_latency, 2),
|
|
59
|
+
"db_queries": self.metrics["db_queries"],
|
|
60
|
+
"agent_tokens": self.metrics["agent_tokens"],
|
|
61
|
+
"recent_traces": self.metrics["custom_traces"][-10:]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
telemetry_store = TelemetryStore()
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger("voodoo.telemetry")
|
|
67
|
+
if not logger.handlers:
|
|
68
|
+
handler = logging.StreamHandler()
|
|
69
|
+
formatter = logging.Formatter('%(asctime)s - %(name)s - [%(trace_id)s] - %(levelname)s - %(message)s')
|
|
70
|
+
handler.setFormatter(formatter)
|
|
71
|
+
logger.addHandler(handler)
|
|
72
|
+
logger.setLevel(logging.INFO)
|
|
73
|
+
|
|
74
|
+
class TraceFilter(logging.Filter):
|
|
75
|
+
def filter(self, record):
|
|
76
|
+
record.trace_id = trace_id_var.get() or "no-trace"
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
logger.addFilter(TraceFilter())
|
|
80
|
+
|
|
81
|
+
class TelemetryMiddleware:
|
|
82
|
+
def __init__(self, app: ASGIApp):
|
|
83
|
+
self.app = app
|
|
84
|
+
|
|
85
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
86
|
+
if scope["type"] not in ("http", "websocket"):
|
|
87
|
+
await self.app(scope, receive, send)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
trace_id = str(uuid.uuid4())
|
|
91
|
+
token = trace_id_var.set(trace_id)
|
|
92
|
+
|
|
93
|
+
start_time = time.perf_counter()
|
|
94
|
+
error = False
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
await self.app(scope, receive, send)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
error = True
|
|
100
|
+
logger.error(f"Unhandled exception in {scope['type']}: {e}\n{traceback.format_exc()}")
|
|
101
|
+
raise
|
|
102
|
+
finally:
|
|
103
|
+
latency_ms = (time.perf_counter() - start_time) * 1000
|
|
104
|
+
telemetry_store.record_request(latency_ms, error)
|
|
105
|
+
|
|
106
|
+
if scope["type"] == "http":
|
|
107
|
+
method = scope.get("method", "")
|
|
108
|
+
path = scope.get("path", "")
|
|
109
|
+
logger.info(f"{method} {path} completed in {latency_ms:.2f}ms")
|
|
110
|
+
elif scope["type"] == "websocket":
|
|
111
|
+
logger.info(f"WebSocket session completed in {latency_ms:.2f}ms")
|
|
112
|
+
|
|
113
|
+
trace_id_var.reset(token)
|
|
114
|
+
|
|
115
|
+
def trace(name: str = None):
|
|
116
|
+
"""Decorator to trace a specific function block."""
|
|
117
|
+
def decorator(func: Callable):
|
|
118
|
+
@functools.wraps(func)
|
|
119
|
+
async def async_wrapper(*args, **kwargs):
|
|
120
|
+
t_name = name or func.__name__
|
|
121
|
+
start = time.perf_counter()
|
|
122
|
+
error = False
|
|
123
|
+
try:
|
|
124
|
+
return await func(*args, **kwargs)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
error = True
|
|
127
|
+
logger.error(f"Error in traced block '{t_name}': {e}\n{traceback.format_exc()}")
|
|
128
|
+
raise
|
|
129
|
+
finally:
|
|
130
|
+
latency = (time.perf_counter() - start) * 1000
|
|
131
|
+
telemetry_store.record_trace(t_name, latency, error)
|
|
132
|
+
logger.info(f"Trace '{t_name}' completed in {latency:.2f}ms")
|
|
133
|
+
|
|
134
|
+
@functools.wraps(func)
|
|
135
|
+
def sync_wrapper(*args, **kwargs):
|
|
136
|
+
t_name = name or func.__name__
|
|
137
|
+
start = time.perf_counter()
|
|
138
|
+
error = False
|
|
139
|
+
try:
|
|
140
|
+
return func(*args, **kwargs)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
error = True
|
|
143
|
+
logger.error(f"Error in traced block '{t_name}': {e}\n{traceback.format_exc()}")
|
|
144
|
+
raise
|
|
145
|
+
finally:
|
|
146
|
+
latency = (time.perf_counter() - start) * 1000
|
|
147
|
+
telemetry_store.record_trace(t_name, latency, error)
|
|
148
|
+
logger.info(f"Trace '{t_name}' completed in {latency:.2f}ms")
|
|
149
|
+
|
|
150
|
+
if inspect.iscoroutinefunction(func):
|
|
151
|
+
return async_wrapper
|
|
152
|
+
return sync_wrapper
|
|
153
|
+
return decorator
|
|
154
|
+
|
|
155
|
+
@api.get("/voodoo/metrics")
|
|
156
|
+
async def get_metrics():
|
|
157
|
+
return telemetry_store.get_summary()
|
voodoo/theme.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
class ThemeColors(BaseModel):
|
|
6
|
+
"""Modern color palette for the theme."""
|
|
7
|
+
primary: str = "#007AFF" # Apple blue
|
|
8
|
+
secondary: str = "#5856D6" # Violet
|
|
9
|
+
|
|
10
|
+
# Dark mode
|
|
11
|
+
background: str = "#0A0A0A" # Charcoal
|
|
12
|
+
surface: str = "rgba(255, 255, 255, 0.05)" # Glass surface
|
|
13
|
+
text: str = "#F3F4F6"
|
|
14
|
+
text_muted: str = "#9CA3AF"
|
|
15
|
+
border: str = "rgba(255, 255, 255, 0.1)"
|
|
16
|
+
|
|
17
|
+
# Light mode
|
|
18
|
+
light_background: str = "#F9FAFB"
|
|
19
|
+
light_surface: str = "rgba(0, 0, 0, 0.05)"
|
|
20
|
+
light_text: str = "#111827"
|
|
21
|
+
light_text_muted: str = "#4B5563"
|
|
22
|
+
light_border: str = "rgba(0, 0, 0, 0.1)"
|
|
23
|
+
|
|
24
|
+
# Allow extra colors
|
|
25
|
+
extra: Dict[str, str] = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
class ThemeFonts(BaseModel):
|
|
28
|
+
"""Typography settings."""
|
|
29
|
+
sans: str = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
|
|
30
|
+
mono: str = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
|
31
|
+
|
|
32
|
+
class Theme(BaseModel):
|
|
33
|
+
"""
|
|
34
|
+
Core Theme configuration for Voodoo.
|
|
35
|
+
Designed for modern macOS 'glass' aesthetic.
|
|
36
|
+
"""
|
|
37
|
+
mode: str = "dark" # dark, light, system
|
|
38
|
+
colors: ThemeColors = Field(default_factory=ThemeColors)
|
|
39
|
+
fonts: ThemeFonts = Field(default_factory=ThemeFonts)
|
|
40
|
+
|
|
41
|
+
def to_tailwind_config(self) -> str:
|
|
42
|
+
"""Generates the Tailwind configuration script."""
|
|
43
|
+
|
|
44
|
+
# Build the colors dictionary for Tailwind
|
|
45
|
+
tw_colors = {
|
|
46
|
+
"primary": self.colors.primary,
|
|
47
|
+
"secondary": self.colors.secondary,
|
|
48
|
+
"background": self.colors.background,
|
|
49
|
+
"surface": self.colors.surface,
|
|
50
|
+
"text": self.colors.text,
|
|
51
|
+
"text-muted": self.colors.text_muted,
|
|
52
|
+
"border": self.colors.border,
|
|
53
|
+
}
|
|
54
|
+
tw_colors.update(self.colors.extra)
|
|
55
|
+
|
|
56
|
+
config = {
|
|
57
|
+
"darkMode": 'class',
|
|
58
|
+
"theme": {
|
|
59
|
+
"extend": {
|
|
60
|
+
"colors": {
|
|
61
|
+
"voodoo": tw_colors
|
|
62
|
+
},
|
|
63
|
+
"fontFamily": {
|
|
64
|
+
"sans": [self.fonts.sans],
|
|
65
|
+
"mono": [self.fonts.mono]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return json.dumps(config)
|
|
71
|
+
|
|
72
|
+
def to_css_variables(self) -> str:
|
|
73
|
+
"""Generates global CSS variables for the theme."""
|
|
74
|
+
dark_vars = [
|
|
75
|
+
f"--color-primary: {self.colors.primary};",
|
|
76
|
+
f"--color-secondary: {self.colors.secondary};",
|
|
77
|
+
f"--color-background: {self.colors.background};",
|
|
78
|
+
f"--color-surface: {self.colors.surface};",
|
|
79
|
+
f"--color-text: {self.colors.text};",
|
|
80
|
+
f"--color-text-muted: {self.colors.text_muted};",
|
|
81
|
+
f"--color-border: {self.colors.border};",
|
|
82
|
+
f"--font-sans: {self.fonts.sans};",
|
|
83
|
+
f"--font-mono: {self.fonts.mono};",
|
|
84
|
+
]
|
|
85
|
+
light_vars = [
|
|
86
|
+
f"--color-primary: {self.colors.primary};",
|
|
87
|
+
f"--color-secondary: {self.colors.secondary};",
|
|
88
|
+
f"--color-background: {self.colors.light_background};",
|
|
89
|
+
f"--color-surface: {self.colors.light_surface};",
|
|
90
|
+
f"--color-text: {self.colors.light_text};",
|
|
91
|
+
f"--color-text-muted: {self.colors.light_text_muted};",
|
|
92
|
+
f"--color-border: {self.colors.light_border};",
|
|
93
|
+
f"--font-sans: {self.fonts.sans};",
|
|
94
|
+
f"--font-mono: {self.fonts.mono};",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
for k, v in self.colors.extra.items():
|
|
98
|
+
dark_vars.append(f"--color-{k}: {v};")
|
|
99
|
+
light_vars.append(f"--color-{k}: {v};")
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
":root {\n " + "\n ".join(light_vars) + "\n}\n" +
|
|
103
|
+
".dark {\n " + "\n ".join(dark_vars) + "\n}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Global default theme
|
|
107
|
+
default_theme = Theme()
|
|
108
|
+
|
|
109
|
+
def set_theme(theme: Theme):
|
|
110
|
+
"""Sets the global default theme."""
|
|
111
|
+
global default_theme
|
|
112
|
+
default_theme = theme
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: voodoo-framework
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Voodoo Framework
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: starlette
|
|
7
|
+
Requires-Dist: uvicorn
|
|
8
|
+
Requires-Dist: pydantic
|
|
9
|
+
Requires-Dist: aiosqlite
|
|
10
|
+
Requires-Dist: websockets
|
|
11
|
+
Requires-Dist: httpx
|
|
12
|
+
Requires-Dist: python-dotenv
|
|
13
|
+
Requires-Dist: pyyaml
|
|
14
|
+
Requires-Dist: typer
|
|
15
|
+
Requires-Dist: rich
|
|
16
|
+
Requires-Dist: openai
|
|
17
|
+
Requires-Dist: aiofiles
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy; extra == "dev"
|
|
23
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
voodoo/__init__.py,sha256=3dz4ShWkS7Wg8g7QgmEeL3xmW7R88MCU053SPBFhZdE,1146
|
|
2
|
+
voodoo/agent.py,sha256=wqqyjHLVb8z9ngG0f59rfozrW7zNH4nMvz1M_GrmeBA,1497
|
|
3
|
+
voodoo/api.py,sha256=LVfpNt9hfB4r0U8mzasoLniOarr-JD6qFmN5Iu7AY_o,5154
|
|
4
|
+
voodoo/cli.py,sha256=BJszwEv5MS8X8bc4NYTSIQUR-cpoCosvbbW1Hv4clec,10465
|
|
5
|
+
voodoo/components.py,sha256=hNkUjk5RBHMs_V33fGsrsqTuVjEltSgVit7Hxv8YDgg,4037
|
|
6
|
+
voodoo/config.py,sha256=ACb3Vvhghm2ZJyHVXArkdGMU-isRWb0yduDr-C71HAw,2144
|
|
7
|
+
voodoo/core.py,sha256=gNi-Rddip3wByZ_ExvaT5io7Vib_mdlvweSfS_YsNcQ,9686
|
|
8
|
+
voodoo/data.py,sha256=9Up4cEF2pX5SeQoxDIDa8LDsJPQoCSViEkWk0suCpqc,5932
|
|
9
|
+
voodoo/i18n.py,sha256=fWjaDYHEKLnd82fujse_m3C_5nAlV0s4Yf-kVaZkyng,3937
|
|
10
|
+
voodoo/mcp.py,sha256=CbfSLYHGMhSYy9EB0QQzWWhmWtY0LQXui0ca5-F7LMc,5415
|
|
11
|
+
voodoo/queue.py,sha256=dZHw9WKIsThu8qDIbeBqKevDk060XH2DDZS3UGj8GVg,1833
|
|
12
|
+
voodoo/status.py,sha256=Esj6pd54-MocvVfq7Jwr9MIdKfGtuM7irfSvvxw2avo,5759
|
|
13
|
+
voodoo/storage.py,sha256=jJPPBoxMRgHPJETHe_SYWHMyoqiPSXmDS4yeRF3cnlQ,3157
|
|
14
|
+
voodoo/telemetry.py,sha256=k3YZRpw5tSIo0xOAoBfxczHf6EHpEI4BEXgJr4o_0DM,5582
|
|
15
|
+
voodoo/theme.py,sha256=40uDhCAX9pw9AfUps8SgFu7AjQ9LTobNxY23aawqMM8,4043
|
|
16
|
+
voodoo_framework-1.0.0.dist-info/METADATA,sha256=yIcnnMd7WrQw1eYsUN1TDbnJDJX2kNH4essiurK4BKI,610
|
|
17
|
+
voodoo_framework-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
voodoo_framework-1.0.0.dist-info/entry_points.txt,sha256=t57b2RrYkby0eg4utUoYjRYKOrHJ1Bzp7t5k_sNq_UQ,42
|
|
19
|
+
voodoo_framework-1.0.0.dist-info/top_level.txt,sha256=TUn9HhnGYiYJyWa6NkouunOjTGCZAGPasucG_BjBT1E,7
|
|
20
|
+
voodoo_framework-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
voodoo
|