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 ADDED
@@ -0,0 +1,51 @@
1
+ from .core import create_app, register_event, ws_manager
2
+ from .components import Div, Button, Input, Card, Text, Heading, ChatBox, Table
3
+ from .data import BaseModel, on_insert, on_update, rls_policy, get_db
4
+ from .queue import queue, enqueue
5
+ from .agent import Agent
6
+ from .api import api
7
+ from .storage import storage
8
+ from .mcp import mcp, MCPClient
9
+ from .status import ServiceStatus
10
+ from .telemetry import trace, TelemetryMiddleware, telemetry_store
11
+ from .config import config
12
+ from .theme import Theme, ThemeColors, set_theme, default_theme
13
+ from .i18n import _, I18n, i18n_instance
14
+
15
+ __all__ = [
16
+ "create_app",
17
+ "register_event",
18
+ "ws_manager",
19
+ "Div",
20
+ "Button",
21
+ "Input",
22
+ "Card",
23
+ "Text",
24
+ "Heading",
25
+ "ChatBox",
26
+ "Table",
27
+ "BaseModel",
28
+ "on_insert",
29
+ "on_update",
30
+ "rls_policy",
31
+ "get_db",
32
+ "queue",
33
+ "enqueue",
34
+ "Agent",
35
+ "api",
36
+ "storage",
37
+ "mcp",
38
+ "MCPClient",
39
+ "ServiceStatus",
40
+ "trace",
41
+ "TelemetryMiddleware",
42
+ "telemetry_store",
43
+ "config",
44
+ "Theme",
45
+ "ThemeColors",
46
+ "set_theme",
47
+ "default_theme",
48
+ "_",
49
+ "I18n",
50
+ "i18n_instance"
51
+ ]
voodoo/agent.py ADDED
@@ -0,0 +1,37 @@
1
+ import asyncio
2
+ from typing import AsyncGenerator, List, Dict
3
+
4
+ class Agent:
5
+ def __init__(self, system_prompt: str = "You are a helpful AI assistant."):
6
+ self.system_prompt = system_prompt
7
+ self.history: List[Dict[str, str]] = [
8
+ {"role": "system", "content": system_prompt}
9
+ ]
10
+
11
+ async def stream(self, prompt: str) -> AsyncGenerator[str, None]:
12
+ from voodoo.telemetry import telemetry_store
13
+ self.history.append({"role": "user", "content": prompt})
14
+
15
+ # In a real scenario, this would connect to OpenAI/Anthropic etc.
16
+ # For this standalone framework, we'll simulate a streaming response
17
+ # or use an actual API if configured.
18
+
19
+ # Simulating a stream of tokens:
20
+ words = f"This is an AI response to: '{prompt}'. In a real app, this streams from an LLM. Here are some more tokens to simulate streaming.".split(" ")
21
+
22
+ telemetry_store.record_agent_tokens(len(prompt.split()) + len(words))
23
+
24
+ full_response = ""
25
+ for word in words:
26
+ await asyncio.sleep(0.1) # Simulate network latency
27
+ token = word + " "
28
+ full_response += token
29
+ yield token
30
+
31
+ self.history.append({"role": "assistant", "content": full_response.strip()})
32
+
33
+ async def run(self, prompt: str) -> str:
34
+ response = ""
35
+ async for chunk in self.stream(prompt):
36
+ response += chunk
37
+ return response
voodoo/api.py ADDED
@@ -0,0 +1,143 @@
1
+ import inspect
2
+ from typing import Any, Callable, Dict, List, Type, Optional, Union, get_type_hints
3
+ from starlette.requests import Request
4
+ from starlette.responses import JSONResponse, HTMLResponse, Response
5
+ from starlette.routing import Route
6
+ from pydantic import BaseModel, create_model
7
+
8
+ class API:
9
+ def __init__(self):
10
+ self.routes: List[Route] = []
11
+ self.paths: Dict[str, Dict[str, Any]] = {}
12
+
13
+ # Add docs routes
14
+ self.routes.append(Route("/openapi.json", self._openapi_schema, methods=["GET"]))
15
+ self.routes.append(Route("/docs", self._swagger_ui, methods=["GET"]))
16
+ self.routes.append(Route("/redoc", self._redoc_ui, methods=["GET"]))
17
+
18
+ def _openapi_schema(self, request: Request):
19
+ schema = {
20
+ "openapi": "3.0.2",
21
+ "info": {"title": "Voodoo API", "version": "1.0.0"},
22
+ "paths": self.paths,
23
+ "components": {"schemas": {}}
24
+ }
25
+ return JSONResponse(schema)
26
+
27
+ def _swagger_ui(self, request: Request):
28
+ html = """
29
+ <!DOCTYPE html>
30
+ <html>
31
+ <head>
32
+ <title>Swagger UI</title>
33
+ <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.0.0/swagger-ui.css" />
34
+ </head>
35
+ <body>
36
+ <div id="swagger-ui"></div>
37
+ <script src="https://unpkg.com/swagger-ui-dist@5.0.0/swagger-ui-bundle.js"></script>
38
+ <script>
39
+ window.onload = () => {
40
+ window.ui = SwaggerUIBundle({
41
+ url: '/openapi.json',
42
+ dom_id: '#swagger-ui',
43
+ });
44
+ };
45
+ </script>
46
+ </body>
47
+ </html>
48
+ """
49
+ return HTMLResponse(html)
50
+
51
+ def _redoc_ui(self, request: Request):
52
+ html = """
53
+ <!DOCTYPE html>
54
+ <html>
55
+ <head>
56
+ <title>ReDoc</title>
57
+ </head>
58
+ <body>
59
+ <redoc spec-url='/openapi.json'></redoc>
60
+ <script src="https://unpkg.com/redoc@2.0.0-rc.53/bundles/redoc.standalone.js"></script>
61
+ </body>
62
+ </html>
63
+ """
64
+ return HTMLResponse(html)
65
+
66
+ def _add_route(self, path: str, method: str, func: Callable):
67
+ # Register in OpenAPI paths
68
+ if path not in self.paths:
69
+ self.paths[path] = {}
70
+
71
+ self.paths[path][method.lower()] = {
72
+ "summary": func.__name__.replace("_", " ").title(),
73
+ "responses": {
74
+ "200": {"description": "Successful Response"}
75
+ }
76
+ }
77
+
78
+ async def endpoint(request: Request):
79
+ sig = inspect.signature(func)
80
+ kwargs = {}
81
+
82
+ for name, param in sig.parameters.items():
83
+ if param.annotation is Request:
84
+ kwargs[name] = request
85
+ elif inspect.isclass(param.annotation) and issubclass(param.annotation, BaseModel):
86
+ # Parse JSON body using Pydantic
87
+ body = await request.json()
88
+ kwargs[name] = param.annotation(**body)
89
+ else:
90
+ # Path or Query param
91
+ if name in request.path_params:
92
+ val = request.path_params[name]
93
+ kwargs[name] = param.annotation(val) if param.annotation != inspect._empty else val
94
+ elif name in request.query_params:
95
+ val = request.query_params[name]
96
+ kwargs[name] = param.annotation(val) if param.annotation != inspect._empty else val
97
+
98
+ if inspect.iscoroutinefunction(func):
99
+ res = await func(**kwargs)
100
+ else:
101
+ res = func(**kwargs)
102
+
103
+ # Serialize response
104
+ if isinstance(res, Response):
105
+ return res
106
+ elif isinstance(res, BaseModel):
107
+ return JSONResponse(res.model_dump())
108
+ elif isinstance(res, list) and len(res) > 0 and isinstance(res[0], BaseModel):
109
+ return JSONResponse([r.model_dump() for r in res])
110
+ elif hasattr(res, "__dict__"): # simple object serialization fallback
111
+ return JSONResponse(res.__dict__)
112
+
113
+ return JSONResponse(res)
114
+
115
+ # Convert FastAPI/Starlette style path params {id} to Starlette path syntax
116
+ # Actually, Starlette uses {id} or {id:int}, so it's compatible.
117
+ self.routes.append(Route(path, endpoint, methods=[method]))
118
+
119
+ def get(self, path: str):
120
+ def decorator(func: Callable):
121
+ self._add_route(path, "GET", func)
122
+ return func
123
+ return decorator
124
+
125
+ def post(self, path: str):
126
+ def decorator(func: Callable):
127
+ self._add_route(path, "POST", func)
128
+ return func
129
+ return decorator
130
+
131
+ def put(self, path: str):
132
+ def decorator(func: Callable):
133
+ self._add_route(path, "PUT", func)
134
+ return func
135
+ return decorator
136
+
137
+ def delete(self, path: str):
138
+ def decorator(func: Callable):
139
+ self._add_route(path, "DELETE", func)
140
+ return func
141
+ return decorator
142
+
143
+ api = API()
voodoo/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ import asyncio
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.progress import Progress, SpinnerColumn, TextColumn
10
+ from rich.panel import Panel
11
+ from rich.markdown import Markdown
12
+
13
+ # We initialize the Typer app
14
+ app = typer.Typer(
15
+ help="🔮 Voodoo Framework CLI - Fast, Animated, AI-Powered",
16
+ no_args_is_help=True,
17
+ add_completion=False,
18
+ )
19
+ console = Console()
20
+
21
+ @app.command()
22
+ def new(
23
+ project_name: str,
24
+ template: str = typer.Option("helderperez-dev/voodoo-templates", "--template", "-t", help="GitHub repository URL or 'user/repo' to use as a template"),
25
+ variant: str = typer.Option("default", "--variant", "-v", help="Specific template variant inside the repository"),
26
+ ):
27
+ """
28
+ Scaffold a new Voodoo project or clone a community template.
29
+ """
30
+ console.print(Panel.fit(f"Creating new Voodoo project: [bold cyan]{project_name}[/bold cyan]", border_style="cyan"))
31
+
32
+ project_dir = Path(project_name)
33
+ if project_dir.exists():
34
+ console.print(f"[bold red]Error:[/bold red] Directory '{project_name}' already exists.")
35
+ raise typer.Exit(1)
36
+
37
+ with Progress(
38
+ SpinnerColumn(),
39
+ TextColumn("[progress.description]{task.description}"),
40
+ transient=True,
41
+ ) as progress:
42
+ if template:
43
+ task = progress.add_task(description=f"Cloning [cyan]{variant}[/cyan] template from [cyan]{template}[/cyan]...", total=None)
44
+
45
+ # Resolve URL
46
+ if template.startswith("http://") or template.startswith("https://") or template.startswith("git@") or template.startswith("/") or template.startswith("file://"):
47
+ repo_url = template
48
+ elif len(template.split("/")) == 2:
49
+ repo_url = f"https://github.com/{template}.git"
50
+ else:
51
+ console.print("\n[bold red]Error:[/bold red] Template must be a valid Git URL, local path, or 'user/repo'.")
52
+ raise typer.Exit(1)
53
+
54
+ import subprocess
55
+ import shutil
56
+ import tempfile
57
+
58
+ fallback_to_offline = False
59
+
60
+ try:
61
+ with tempfile.TemporaryDirectory() as tmp_dir:
62
+ subprocess.run(
63
+ ["git", "clone", "--depth", "1", repo_url, tmp_dir],
64
+ check=True,
65
+ stdout=subprocess.PIPE,
66
+ stderr=subprocess.PIPE
67
+ )
68
+
69
+ variant_path = Path(tmp_dir) / variant
70
+
71
+ if not variant_path.exists() or not variant_path.is_dir():
72
+ # If variant doesn't exist, check if the repo root itself is the template
73
+ if variant == "default" and not (Path(tmp_dir) / "default").exists():
74
+ variant_path = Path(tmp_dir)
75
+ else:
76
+ console.print(f"\n[bold red]Error:[/bold red] Variant '{variant}' not found in template repository.")
77
+ raise typer.Exit(1)
78
+
79
+ # Copy the template files over to the project directory
80
+ shutil.copytree(variant_path, project_dir, dirs_exist_ok=True)
81
+
82
+ except subprocess.CalledProcessError as e:
83
+ console.print(f"\n[bold yellow]Warning:[/bold yellow] Failed to clone template from {repo_url}")
84
+ console.print("[yellow]Falling back to offline default scaffolding...[/yellow]")
85
+ fallback_to_offline = True
86
+
87
+ if not fallback_to_offline:
88
+ # Remove the .git folder so the user starts with a clean slate
89
+ if (project_dir / ".git").exists():
90
+ shutil.rmtree(project_dir / ".git", ignore_errors=True)
91
+
92
+ if not (project_dir / ".data").exists():
93
+ os.makedirs(project_dir / ".data", exist_ok=True)
94
+
95
+ if not template or fallback_to_offline:
96
+ task = progress.add_task(description="Scaffolding offline project structure...", total=None)
97
+
98
+ # Simulate quick but visible animation
99
+ time.sleep(0.5)
100
+
101
+ os.makedirs(project_dir)
102
+ os.makedirs(project_dir / "app")
103
+ os.makedirs(project_dir / ".data")
104
+
105
+ progress.update(task, description="Writing base configuration...")
106
+ time.sleep(0.5)
107
+
108
+ (project_dir / ".env").write_text("VOODOO_DB_PATH=.data/voodoo.db\n")
109
+ (project_dir / "pyproject.toml").write_text(f"""[project]
110
+ name = "{project_name}"
111
+ version = "0.1.0"
112
+ dependencies = [
113
+ "voodoo-framework"
114
+ ]
115
+ """)
116
+
117
+ progress.update(task, description="Generating entry point...")
118
+ time.sleep(0.5)
119
+
120
+ (project_dir / "app" / "page.py").write_text("""from voodoo.components import Div, Heading, Text
121
+
122
+ def page(request):
123
+ \"\"\"
124
+ A minimal single-page application.
125
+ Voodoo's router will automatically map app/page.py to the root "/" route.
126
+ \"\"\"
127
+ return Div(
128
+ Heading("Hello, Voodoo! 🪄", level=1, className="text-5xl font-bold text-center mt-32 tracking-tight"),
129
+ Div(Text("Welcome to your new Voodoo app."), className="text-center text-[var(--color-text-muted)] mt-6 text-lg"),
130
+ className="min-h-screen bg-[var(--color-background)] text-[var(--color-text)]"
131
+ )
132
+ """)
133
+
134
+ (project_dir / "main.py").write_text("""import uvicorn
135
+ from voodoo.core import create_app
136
+ from voodoo.config import config
137
+
138
+ # Voodoo automatically looks for the "app" folder in the current working directory
139
+ app = create_app()
140
+
141
+ if __name__ == "__main__":
142
+ uvicorn.run("main:app", host=config.host, port=config.port, reload=True, ws_max_size=16777216, ws_max_queue=32)
143
+ """)
144
+
145
+ console.print("[bold green]✓ Project scaffolded successfully![/bold green]")
146
+ console.print(f"\nNext steps:\n [cyan]cd {project_name}[/cyan]\n [cyan]voodoo dev[/cyan]\n")
147
+
148
+ @app.command()
149
+ def dev(
150
+ app_str: str = typer.Argument("main:app", help="App instance to run (e.g., main:app)"),
151
+ port: int = typer.Option(8000, help="Port to run the server on"),
152
+ ):
153
+ """
154
+ Start the Voodoo development server.
155
+ """
156
+ module_name = app_str.split(":")[0]
157
+ module_path = Path(module_name.replace(".", "/") + ".py")
158
+ module_dir = Path(module_name.replace(".", "/"))
159
+
160
+ if not module_path.exists() and not (module_dir.is_dir() and (module_dir / "__init__.py").exists()):
161
+ console.print(f"\n[bold red]Error:[/bold red] Could not find module [yellow]{module_name}[/yellow].")
162
+ console.print("Are you sure you are inside a Voodoo project directory?")
163
+ console.print("To start a new project, run: [bold cyan]voodoo new <project_name>[/bold cyan]\n")
164
+ raise typer.Exit(1)
165
+
166
+ console.print(Panel.fit(f"Starting Voodoo Server on port [bold yellow]{port}[/bold yellow]", border_style="yellow"))
167
+
168
+ # We use a subprocess to run uvicorn
169
+ import subprocess
170
+
171
+ try:
172
+ # We let uvicorn take over the terminal output
173
+ subprocess.run(["uvicorn", app_str, "--reload", "--port", str(port)])
174
+ except KeyboardInterrupt:
175
+ console.print("\n[bold red]Server stopped.[/bold red]")
176
+
177
+ @app.command()
178
+ def generate(
179
+ component: str = typer.Argument(..., help="Component type (e.g., agent, resource, tool)"),
180
+ description: str = typer.Argument(..., help="What should the AI generate?"),
181
+ ):
182
+ """
183
+ AI-powered generation of Voodoo components using LLMs.
184
+ """
185
+ from openai import AsyncOpenAI
186
+
187
+ # Check for API keys (support OpenRouter or OpenAI)
188
+ api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
189
+ base_url = "https://openrouter.ai/api/v1" if os.getenv("OPENROUTER_API_KEY") else None
190
+
191
+ if not api_key:
192
+ console.print("[bold red]Error:[/bold red] Neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set in the environment.")
193
+ raise typer.Exit(1)
194
+
195
+ client = AsyncOpenAI(api_key=api_key, base_url=base_url)
196
+ model = "openai/gpt-4o" if base_url else "gpt-4o"
197
+
198
+ async def _generate():
199
+ with Progress(
200
+ SpinnerColumn(),
201
+ TextColumn("[progress.description]{task.description}"),
202
+ transient=True,
203
+ ) as progress:
204
+ progress.add_task(description=f"AI is thinking about your [bold magenta]{component}[/bold magenta]...", total=None)
205
+
206
+ prompt = f"""
207
+ You are an expert Voodoo Framework developer. Voodoo is a modern Python framework built on Starlette and Pydantic.
208
+ Generate a Voodoo `{component}` based on this description: "{description}".
209
+
210
+ Only output the raw Python code. Do not include markdown code blocks (no ```python).
211
+ Do not include explanations. Just the raw code.
212
+ """
213
+
214
+ try:
215
+ response = await client.chat.completions.create(
216
+ model=model,
217
+ messages=[{"role": "user", "content": prompt}],
218
+ temperature=0.2
219
+ )
220
+
221
+ code = response.choices[0].message.content.strip()
222
+ # Clean up if the model accidentally included markdown blocks
223
+ if code.startswith("```python"):
224
+ code = code[9:]
225
+ if code.startswith("```"):
226
+ code = code[3:]
227
+ if code.endswith("```"):
228
+ code = code[:-3]
229
+
230
+ return code.strip()
231
+
232
+ except Exception as e:
233
+ console.print(f"[bold red]Failed to generate code:[/bold red] {e}")
234
+ raise typer.Exit(1)
235
+
236
+ # Run async function
237
+ code = asyncio.run(_generate())
238
+
239
+ # Save the file
240
+ filename = f"{component}_{int(time.time())}.py"
241
+ Path(filename).write_text(code + "\n")
242
+
243
+ console.print(f"[bold green]✓ Generated {component} successfully![/bold green]")
244
+ console.print(f"Saved to: [bold cyan]{filename}[/bold cyan]")
245
+
246
+ # Show preview
247
+ console.print(Panel(code, title=f"Preview: {filename}", border_style="green"))
248
+
249
+ if __name__ == "__main__":
250
+ app()
voodoo/components.py ADDED
@@ -0,0 +1,107 @@
1
+ import uuid
2
+ from typing import Any, List
3
+
4
+ class Component:
5
+ tag = "div"
6
+
7
+ def __init__(self, *children, id=None, **kwargs):
8
+ self.id = id or f"vd-{uuid.uuid4().hex[:8]}"
9
+ self.children = children
10
+ self.attributes = kwargs
11
+
12
+ def render(self) -> str:
13
+ attrs = [f'id="{self.id}"']
14
+ for k, v in self.attributes.items():
15
+ k = k.replace("_", "-")
16
+ if k == "className":
17
+ k = "class"
18
+ if v is not None and v is not False:
19
+ if v is True:
20
+ attrs.append(f'{k}')
21
+ else:
22
+ attrs.append(f'{k}="{v}"')
23
+
24
+ attr_str = " " + " ".join(attrs) if attrs else ""
25
+
26
+ rendered_children = ""
27
+ for child in self.children:
28
+ if isinstance(child, Component):
29
+ rendered_children += child.render()
30
+ else:
31
+ rendered_children += str(child)
32
+
33
+ # Self closing tags
34
+ if self.tag in ["input", "img", "br", "hr"]:
35
+ return f"<{self.tag}{attr_str} />"
36
+
37
+ return f"<{self.tag}{attr_str}>{rendered_children}</{self.tag}>"
38
+
39
+ class Div(Component):
40
+ tag = "div"
41
+
42
+ class Button(Component):
43
+ tag = "button"
44
+
45
+ def __init__(self, *children, on_click=None, **kwargs):
46
+ if on_click:
47
+ kwargs["onclick"] = f"voodoo.sendEvent('{on_click}', this.id, this.value)"
48
+ super().__init__(*children, **kwargs)
49
+
50
+ class Input(Component):
51
+ tag = "input"
52
+
53
+ def __init__(self, *children, on_change=None, **kwargs):
54
+ if on_change:
55
+ kwargs["onchange"] = f"voodoo.sendEvent('{on_change}', this.id, this.value)"
56
+ super().__init__(*children, **kwargs)
57
+
58
+ class Card(Component):
59
+ tag = "div"
60
+ def __init__(self, *children, **kwargs):
61
+ classes = kwargs.get("className", "")
62
+ # Remove default conflicting classes if user provides background/border
63
+ default_bg = "bg-[var(--color-surface)]" if "bg-" not in classes else ""
64
+ default_border = "border border-[var(--color-border)]" if "border" not in classes else ""
65
+ kwargs["className"] = f"{default_bg} {default_border} rounded-xl p-6 shadow-xl {classes}".strip()
66
+ super().__init__(*children, **kwargs)
67
+
68
+ class Text(Component):
69
+ tag = "span"
70
+
71
+ class Heading(Component):
72
+ tag = "h1"
73
+ def __init__(self, *children, level=1, **kwargs):
74
+ self.tag = f"h{level}"
75
+ super().__init__(*children, **kwargs)
76
+
77
+ class ChatBox(Component):
78
+ tag = "div"
79
+ def __init__(self, *children, **kwargs):
80
+ classes = kwargs.get("className", "")
81
+ kwargs["className"] = f"flex flex-col space-y-2 overflow-y-auto {classes}".strip()
82
+ super().__init__(*children, **kwargs)
83
+
84
+ class Table(Component):
85
+ tag = "table"
86
+ def __init__(self, headers: List[str], rows: List[List[Any]], **kwargs):
87
+ super().__init__(**kwargs)
88
+ self.headers = headers
89
+ self.rows = rows
90
+
91
+ def render(self) -> str:
92
+ th_cells = "".join(f'<th class="px-6 py-4 text-left text-xs font-medium text-[var(--color-text-muted)] uppercase tracking-wider">{h}</th>' for h in self.headers)
93
+ thead = f"<thead class='bg-[var(--color-surface)] border-b border-[var(--color-border)]'><tr>{th_cells}</tr></thead>"
94
+ tbody_rows = []
95
+ for row in self.rows:
96
+ tds = "".join(f'<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--color-text)]">{cell}</td>' for cell in row)
97
+ tbody_rows.append(f"<tr class='border-b border-[var(--color-border)] hover:bg-[var(--color-surface)] transition-colors'>{tds}</tr>")
98
+ tbody = f"<tbody>{''.join(tbody_rows)}</tbody>"
99
+
100
+ attrs = [f'id="{self.id}"']
101
+ for k, v in self.attributes.items():
102
+ if k == "className":
103
+ k = "class"
104
+ attrs.append(f'{k}="{v}"')
105
+
106
+ attr_str = " " + " ".join(attrs) if attrs else ""
107
+ return f"<table{attr_str}>{thead}{tbody}</table>"
voodoo/config.py ADDED
@@ -0,0 +1,51 @@
1
+ import os
2
+ import yaml
3
+ from typing import Any, Dict, Optional
4
+ from pydantic import BaseModel, Field
5
+ from dotenv import load_dotenv
6
+
7
+ # Load .env variables first
8
+ load_dotenv()
9
+
10
+ class VoodooConfig(BaseModel):
11
+ """Core configuration for the Voodoo framework."""
12
+ env: str = Field(default_factory=lambda: os.getenv("VOODOO_ENV", "development"))
13
+ db_path: str = Field(default_factory=lambda: os.getenv("VOODOO_DB_PATH", ".data/voodoo.db"))
14
+ storage_dir: str = Field(default_factory=lambda: os.getenv("VOODOO_STORAGE_DIR", "storage"))
15
+ port: int = Field(default_factory=lambda: int(os.getenv("VOODOO_PORT", "8000")))
16
+ host: str = Field(default_factory=lambda: os.getenv("VOODOO_HOST", "0.0.0.0"))
17
+ extra: Dict[str, Any] = Field(default_factory=dict)
18
+
19
+ def load_yaml_config(file_path: str = "voodoo.yaml") -> Dict[str, Any]:
20
+ """Loads configuration from a YAML file if it exists."""
21
+ if os.path.exists(file_path):
22
+ with open(file_path, "r") as f:
23
+ try:
24
+ return yaml.safe_load(f) or {}
25
+ except yaml.YAMLError as e:
26
+ print(f"Error parsing {file_path}: {e}")
27
+ return {}
28
+
29
+ def get_config() -> VoodooConfig:
30
+ """Gets the merged configuration from env vars and YAML."""
31
+ yaml_data = load_yaml_config()
32
+
33
+ # We allow yaml to override or extend defaults
34
+ # but environment variables usually take precedence in production.
35
+ # For this simple implementation, we'll merge them.
36
+ config_args = {}
37
+
38
+ # Add mapped YAML fields if they match our known fields
39
+ if "env" in yaml_data: config_args["env"] = yaml_data["env"]
40
+ if "db_path" in yaml_data: config_args["db_path"] = yaml_data["db_path"]
41
+ if "storage_dir" in yaml_data: config_args["storage_dir"] = yaml_data["storage_dir"]
42
+ if "port" in yaml_data: config_args["port"] = yaml_data["port"]
43
+ if "host" in yaml_data: config_args["host"] = yaml_data["host"]
44
+
45
+ # Store any extra custom configuration
46
+ config_args["extra"] = {k: v for k, v in yaml_data.items() if k not in config_args}
47
+
48
+ return VoodooConfig(**config_args)
49
+
50
+ # Global config instance
51
+ config = get_config()