voodoo-framework 1.0.0__tar.gz
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_framework-1.0.0/PKG-INFO +23 -0
- voodoo_framework-1.0.0/README.md +78 -0
- voodoo_framework-1.0.0/pyproject.toml +92 -0
- voodoo_framework-1.0.0/setup.cfg +4 -0
- voodoo_framework-1.0.0/tests/test_components.py +53 -0
- voodoo_framework-1.0.0/tests/test_data.py +95 -0
- voodoo_framework-1.0.0/tests/test_i18n.py +73 -0
- voodoo_framework-1.0.0/tests/test_queue.py +35 -0
- voodoo_framework-1.0.0/tests/test_theme.py +33 -0
- voodoo_framework-1.0.0/tests/test_websocket.py +52 -0
- voodoo_framework-1.0.0/voodoo/__init__.py +51 -0
- voodoo_framework-1.0.0/voodoo/agent.py +37 -0
- voodoo_framework-1.0.0/voodoo/api.py +143 -0
- voodoo_framework-1.0.0/voodoo/cli.py +176 -0
- voodoo_framework-1.0.0/voodoo/components.py +107 -0
- voodoo_framework-1.0.0/voodoo/config.py +51 -0
- voodoo_framework-1.0.0/voodoo/core.py +236 -0
- voodoo_framework-1.0.0/voodoo/data.py +179 -0
- voodoo_framework-1.0.0/voodoo/i18n.py +107 -0
- voodoo_framework-1.0.0/voodoo/mcp.py +141 -0
- voodoo_framework-1.0.0/voodoo/queue.py +61 -0
- voodoo_framework-1.0.0/voodoo/status.py +131 -0
- voodoo_framework-1.0.0/voodoo/storage.py +85 -0
- voodoo_framework-1.0.0/voodoo/telemetry.py +157 -0
- voodoo_framework-1.0.0/voodoo/theme.py +112 -0
- voodoo_framework-1.0.0/voodoo_framework.egg-info/PKG-INFO +23 -0
- voodoo_framework-1.0.0/voodoo_framework.egg-info/SOURCES.txt +29 -0
- voodoo_framework-1.0.0/voodoo_framework.egg-info/dependency_links.txt +1 -0
- voodoo_framework-1.0.0/voodoo_framework.egg-info/entry_points.txt +2 -0
- voodoo_framework-1.0.0/voodoo_framework.egg-info/requires.txt +19 -0
- voodoo_framework-1.0.0/voodoo_framework.egg-info/top_level.txt +1 -0
|
@@ -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,78 @@
|
|
|
1
|
+
# Voodoo Framework 🪄
|
|
2
|
+
|
|
3
|
+
**The Modern, Full-Stack Async Python Web Framework.**
|
|
4
|
+
|
|
5
|
+
Voodoo is an ultra-minimalist, high-performance web framework designed for the AI era. Built on top of Starlette and Uvicorn, it offers a seamless, reactive developer experience by combining a React-like component system in pure Python with robust backend features like background queues, real-time websockets, LLM agents, and built-in SQLite persistence.
|
|
6
|
+
|
|
7
|
+
## ✨ Features
|
|
8
|
+
|
|
9
|
+
- **Component-Driven UI**: Build modern, reactive UIs using pure Python components (`Div`, `Card`, `Button`, `Text`) with TailwindCSS integration out of the box. No writing raw HTML/JS required.
|
|
10
|
+
- **Real-Time WebSockets**: Live updates pushed seamlessly to the client with minimal configuration.
|
|
11
|
+
- **Data Persistence**: Built-in async SQLite (`aiosqlite`) ORM using Pydantic models with Row-Level Security (RLS) policies.
|
|
12
|
+
- **Background Workers**: Native task queues for heavy processing, ideal for AI/LLM workloads.
|
|
13
|
+
- **AI Native**: Built-in support for LLM Agents and the Model Context Protocol (MCP).
|
|
14
|
+
- **Observability**: Integrated Telemetry and APM tracing module tracking latencies, metrics, and AI token usage.
|
|
15
|
+
- **Zero Config**: `pyproject.toml` based, zero-configuration setup, hot-reloading, and out-of-the-box smart documentation.
|
|
16
|
+
|
|
17
|
+
## 🚀 Getting Started
|
|
18
|
+
|
|
19
|
+
### Prerequisites
|
|
20
|
+
|
|
21
|
+
- Python 3.12+
|
|
22
|
+
- `pip`
|
|
23
|
+
|
|
24
|
+
### Installation
|
|
25
|
+
|
|
26
|
+
Clone the repository and install the framework in editable mode with development dependencies:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/your-org/voodoo-framework.git
|
|
30
|
+
cd voodoo-framework
|
|
31
|
+
pip install -e .[dev]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Running the App
|
|
35
|
+
|
|
36
|
+
Start the development server using the CLI:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
voodoo dev
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The application will start on `http://localhost:8000`.
|
|
43
|
+
Check out the built-in Smart Documentation at `http://localhost:8000/docs` to see all available components and APIs.
|
|
44
|
+
|
|
45
|
+
## 📖 Smart Documentation
|
|
46
|
+
|
|
47
|
+
Voodoo ships with its own interactive documentation built using the framework itself. When you run the application, navigate to `/docs` in your browser to view:
|
|
48
|
+
|
|
49
|
+
- Component Library References
|
|
50
|
+
- API Integration Guides
|
|
51
|
+
- Framework Telemetry Status
|
|
52
|
+
- Live Examples
|
|
53
|
+
|
|
54
|
+
## 🛠Project Structure
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
voodoo-framework/
|
|
58
|
+
├── app/ # Your application code
|
|
59
|
+
│ ├── pages/ # File-based routing (index.py, docs.py)
|
|
60
|
+
│ ├── layout.py # Main layout component
|
|
61
|
+
│ ├── models.py # Pydantic data models
|
|
62
|
+
│ └── workers.py # Background task handlers
|
|
63
|
+
├── voodoo/ # Framework core source
|
|
64
|
+
├── tests/ # Pytest async test suite
|
|
65
|
+
└── pyproject.toml # Project metadata and dependencies
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 🧪 Testing
|
|
69
|
+
|
|
70
|
+
Run the test suite using `pytest`:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pytest
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 📜 License
|
|
77
|
+
|
|
78
|
+
MIT License. See `LICENSE` for more information.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "voodoo-framework"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Voodoo Framework"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"starlette",
|
|
12
|
+
"uvicorn",
|
|
13
|
+
"pydantic",
|
|
14
|
+
"aiosqlite",
|
|
15
|
+
"websockets",
|
|
16
|
+
"httpx",
|
|
17
|
+
"python-dotenv",
|
|
18
|
+
"pyyaml",
|
|
19
|
+
"typer",
|
|
20
|
+
"rich",
|
|
21
|
+
"openai",
|
|
22
|
+
"aiofiles"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
voodoo = "voodoo.cli:app"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest",
|
|
31
|
+
"pytest-asyncio",
|
|
32
|
+
"ruff",
|
|
33
|
+
"mypy",
|
|
34
|
+
"pre-commit"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["."]
|
|
39
|
+
include = ["voodoo*", "app*"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
asyncio_mode = "auto"
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
target-version = "py312"
|
|
47
|
+
line-length = 88
|
|
48
|
+
exclude = [
|
|
49
|
+
".git",
|
|
50
|
+
".mypy_cache",
|
|
51
|
+
".pytest_cache",
|
|
52
|
+
".ruff_cache",
|
|
53
|
+
".venv",
|
|
54
|
+
"__pycache__",
|
|
55
|
+
"build",
|
|
56
|
+
"dist",
|
|
57
|
+
"venv",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = [
|
|
62
|
+
"E", # pycodestyle errors
|
|
63
|
+
"W", # pycodestyle warnings
|
|
64
|
+
"F", # pyflakes
|
|
65
|
+
"I", # isort
|
|
66
|
+
"C", # flake8-comprehensions
|
|
67
|
+
"B", # flake8-bugbear
|
|
68
|
+
"UP", # pyupgrade
|
|
69
|
+
]
|
|
70
|
+
ignore = []
|
|
71
|
+
|
|
72
|
+
[tool.ruff.format]
|
|
73
|
+
quote-style = "double"
|
|
74
|
+
indent-style = "space"
|
|
75
|
+
skip-magic-trailing-comma = false
|
|
76
|
+
line-ending = "auto"
|
|
77
|
+
|
|
78
|
+
[tool.mypy]
|
|
79
|
+
python_version = "3.12"
|
|
80
|
+
warn_return_any = true
|
|
81
|
+
warn_unused_configs = true
|
|
82
|
+
disallow_untyped_defs = true
|
|
83
|
+
disallow_incomplete_defs = true
|
|
84
|
+
check_untyped_defs = true
|
|
85
|
+
disallow_untyped_decorators = false
|
|
86
|
+
no_implicit_optional = true
|
|
87
|
+
strict_optional = true
|
|
88
|
+
|
|
89
|
+
[[tool.mypy.overrides]]
|
|
90
|
+
module = "tests.*"
|
|
91
|
+
disallow_untyped_defs = false
|
|
92
|
+
disallow_incomplete_defs = false
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from voodoo.components import Component, Div, Button, Input, Card, Text, Heading, ChatBox, Table
|
|
3
|
+
|
|
4
|
+
def test_component_base():
|
|
5
|
+
comp = Component("Hello", id="test-1", className="bg-red-500", data_custom="value")
|
|
6
|
+
html = comp.render()
|
|
7
|
+
assert html == '<div id="test-1" class="bg-red-500" data-custom="value">Hello</div>'
|
|
8
|
+
|
|
9
|
+
def test_div():
|
|
10
|
+
div = Div("Content", id="d1")
|
|
11
|
+
assert div.render() == '<div id="d1">Content</div>'
|
|
12
|
+
|
|
13
|
+
def test_button():
|
|
14
|
+
btn = Button("Click Me", id="btn-1", on_click="my_action")
|
|
15
|
+
html = btn.render()
|
|
16
|
+
assert html == '<button id="btn-1" onclick="voodoo.sendEvent(\'my_action\', this.id, this.value)">Click Me</button>'
|
|
17
|
+
|
|
18
|
+
def test_input():
|
|
19
|
+
inp = Input(id="inp-1", on_change="input_changed", type="text")
|
|
20
|
+
html = inp.render()
|
|
21
|
+
assert html == '<input id="inp-1" type="text" onchange="voodoo.sendEvent(\'input_changed\', this.id, this.value)" />'
|
|
22
|
+
|
|
23
|
+
def test_card():
|
|
24
|
+
card = Card("Card Content", id="card-1", className="extra-class")
|
|
25
|
+
html = card.render()
|
|
26
|
+
assert html == '<div id="card-1" class="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-6 shadow-xl extra-class">Card Content</div>'
|
|
27
|
+
|
|
28
|
+
def test_text():
|
|
29
|
+
text = Text("Span Text", id="txt-1")
|
|
30
|
+
assert text.render() == '<span id="txt-1">Span Text</span>'
|
|
31
|
+
|
|
32
|
+
def test_heading():
|
|
33
|
+
h1 = Heading("H1 Title", id="h-1")
|
|
34
|
+
assert h1.render() == '<h1 id="h-1">H1 Title</h1>'
|
|
35
|
+
|
|
36
|
+
h3 = Heading("H3 Title", id="h-3", level=3)
|
|
37
|
+
assert h3.render() == '<h3 id="h-3">H3 Title</h3>'
|
|
38
|
+
|
|
39
|
+
def test_chatbox():
|
|
40
|
+
box = ChatBox("Messages", id="chat-1")
|
|
41
|
+
assert box.render() == '<div id="chat-1" class="flex flex-col space-y-2 overflow-y-auto">Messages</div>'
|
|
42
|
+
|
|
43
|
+
def test_table():
|
|
44
|
+
table = Table(
|
|
45
|
+
headers=["Name", "Age"],
|
|
46
|
+
rows=[["Alice", 30], ["Bob", 25]],
|
|
47
|
+
id="tbl-1",
|
|
48
|
+
className="my-table"
|
|
49
|
+
)
|
|
50
|
+
html = table.render()
|
|
51
|
+
assert html.startswith('<table id="tbl-1" class="my-table">')
|
|
52
|
+
assert '<th class="px-6 py-4 text-left text-xs font-medium text-[var(--color-text-muted)] uppercase tracking-wider">Name</th>' in html
|
|
53
|
+
assert '<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--color-text)]">Alice</td>' in html
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import pytest_asyncio
|
|
3
|
+
import asyncio
|
|
4
|
+
from voodoo.data import BaseModel, on_insert, on_update, rls_policy
|
|
5
|
+
|
|
6
|
+
class User(BaseModel):
|
|
7
|
+
name: str
|
|
8
|
+
age: int
|
|
9
|
+
is_active: bool
|
|
10
|
+
|
|
11
|
+
class Post(BaseModel):
|
|
12
|
+
title: str
|
|
13
|
+
content: str
|
|
14
|
+
user_id: int
|
|
15
|
+
|
|
16
|
+
inserted_users = []
|
|
17
|
+
updated_users = []
|
|
18
|
+
|
|
19
|
+
@on_insert(User)
|
|
20
|
+
async def hook_insert_user(user):
|
|
21
|
+
inserted_users.append(user.name)
|
|
22
|
+
|
|
23
|
+
@on_update(User)
|
|
24
|
+
def hook_update_user(user):
|
|
25
|
+
updated_users.append(user.name)
|
|
26
|
+
|
|
27
|
+
@rls_policy(Post)
|
|
28
|
+
def post_policy(context):
|
|
29
|
+
if context.get("role") == "admin":
|
|
30
|
+
return None
|
|
31
|
+
return f"user_id = {context.get('user_id', 0)}"
|
|
32
|
+
|
|
33
|
+
@pytest_asyncio.fixture(autouse=True)
|
|
34
|
+
async def clear_db(test_db):
|
|
35
|
+
await test_db.execute("DELETE FROM user")
|
|
36
|
+
await test_db.execute("DELETE FROM post")
|
|
37
|
+
await test_db.commit()
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_orm_operations(test_db):
|
|
41
|
+
# Insert
|
|
42
|
+
u = User()
|
|
43
|
+
u.name = "Alice"
|
|
44
|
+
u.age = 30
|
|
45
|
+
u.is_active = True
|
|
46
|
+
await u.insert()
|
|
47
|
+
|
|
48
|
+
assert u.id is not None
|
|
49
|
+
|
|
50
|
+
# Wait for the async trigger to execute
|
|
51
|
+
await asyncio.sleep(0.01)
|
|
52
|
+
assert "Alice" in inserted_users
|
|
53
|
+
|
|
54
|
+
# Find all
|
|
55
|
+
users = await User.find_all()
|
|
56
|
+
assert len(users) == 1
|
|
57
|
+
assert users[0].name == "Alice"
|
|
58
|
+
assert users[0].age == 30
|
|
59
|
+
assert users[0].is_active is True
|
|
60
|
+
|
|
61
|
+
# Update
|
|
62
|
+
u.age = 31
|
|
63
|
+
await u.update()
|
|
64
|
+
|
|
65
|
+
# Wait for the sync trigger to execute (called directly in update)
|
|
66
|
+
await asyncio.sleep(0.01)
|
|
67
|
+
assert "Alice" in updated_users
|
|
68
|
+
|
|
69
|
+
users = await User.find_all()
|
|
70
|
+
assert users[0].age == 31
|
|
71
|
+
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def test_rls_policies(test_db):
|
|
74
|
+
p1 = Post()
|
|
75
|
+
p1.title = "Hello"
|
|
76
|
+
p1.content = "World"
|
|
77
|
+
p1.user_id = 1
|
|
78
|
+
await p1.insert()
|
|
79
|
+
|
|
80
|
+
p2 = Post()
|
|
81
|
+
p2.title = "Secret"
|
|
82
|
+
p2.content = "Admin only"
|
|
83
|
+
p2.user_id = 2
|
|
84
|
+
await p2.insert()
|
|
85
|
+
|
|
86
|
+
# User 1 context
|
|
87
|
+
user_context = {"user_id": 1, "role": "user"}
|
|
88
|
+
posts = await Post.find_all(user_context=user_context)
|
|
89
|
+
assert len(posts) == 1
|
|
90
|
+
assert posts[0].title == "Hello"
|
|
91
|
+
|
|
92
|
+
# Admin context
|
|
93
|
+
admin_context = {"user_id": 99, "role": "admin"}
|
|
94
|
+
all_posts = await Post.find_all(user_context=admin_context)
|
|
95
|
+
assert len(all_posts) == 2
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import pytest
|
|
4
|
+
from starlette.testclient import TestClient
|
|
5
|
+
from starlette.applications import Starlette
|
|
6
|
+
from starlette.routing import Route
|
|
7
|
+
from starlette.responses import PlainTextResponse
|
|
8
|
+
|
|
9
|
+
from voodoo.i18n import I18n, _, current_language, I18nMiddleware
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def mock_locales(tmp_path):
|
|
13
|
+
locales_dir = tmp_path / "locales"
|
|
14
|
+
locales_dir.mkdir()
|
|
15
|
+
|
|
16
|
+
en_data = {"hello": "Hello", "nested": {"key": "Value {name}"}}
|
|
17
|
+
pt_data = {"hello": "Olá", "nested": {"key": "Valor {name}"}}
|
|
18
|
+
|
|
19
|
+
(locales_dir / "en.json").write_text(json.dumps(en_data), encoding="utf-8")
|
|
20
|
+
(locales_dir / "pt.json").write_text(json.dumps(pt_data), encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
return str(locales_dir)
|
|
23
|
+
|
|
24
|
+
def test_i18n_loading(mock_locales):
|
|
25
|
+
i18n = I18n(locales_dir=mock_locales)
|
|
26
|
+
|
|
27
|
+
assert i18n.get_translation("en", "hello") == "Hello"
|
|
28
|
+
assert i18n.get_translation("pt", "hello") == "Olá"
|
|
29
|
+
|
|
30
|
+
# Fallback to default
|
|
31
|
+
assert i18n.get_translation("es", "hello") == "Hello"
|
|
32
|
+
|
|
33
|
+
# Missing key
|
|
34
|
+
assert i18n.get_translation("en", "missing.key") == "missing.key"
|
|
35
|
+
|
|
36
|
+
# Interpolation
|
|
37
|
+
assert i18n.get_translation("en", "nested.key", name="World") == "Value World"
|
|
38
|
+
assert i18n.get_translation("pt", "nested.key", name="Mundo") == "Valor Mundo"
|
|
39
|
+
|
|
40
|
+
def test_i18n_middleware(mock_locales):
|
|
41
|
+
i18n = I18n(locales_dir=mock_locales)
|
|
42
|
+
|
|
43
|
+
async def homepage(request):
|
|
44
|
+
# We temporarily override the global i18n_instance in the test
|
|
45
|
+
# but here we can just use the context var directly
|
|
46
|
+
import voodoo.i18n
|
|
47
|
+
voodoo.i18n.i18n_instance = i18n
|
|
48
|
+
|
|
49
|
+
return PlainTextResponse(f"Lang: {current_language.get()} - Msg: {_('hello')}")
|
|
50
|
+
|
|
51
|
+
app = Starlette(routes=[Route("/", homepage)])
|
|
52
|
+
app.add_middleware(I18nMiddleware, i18n=i18n)
|
|
53
|
+
|
|
54
|
+
client = TestClient(app)
|
|
55
|
+
|
|
56
|
+
# 1. Default
|
|
57
|
+
resp = client.get("/")
|
|
58
|
+
assert resp.text == "Lang: en - Msg: Hello"
|
|
59
|
+
|
|
60
|
+
# 2. Accept-Language header
|
|
61
|
+
resp = client.get("/", headers={"Accept-Language": "pt-BR,pt;q=0.9,en-US;q=0.8"})
|
|
62
|
+
assert resp.text == "Lang: pt - Msg: Olá"
|
|
63
|
+
|
|
64
|
+
# 3. Cookie
|
|
65
|
+
client.cookies = {"voodoo_lang": "pt"}
|
|
66
|
+
resp = client.get("/")
|
|
67
|
+
assert resp.text == "Lang: pt - Msg: Olá"
|
|
68
|
+
|
|
69
|
+
# 4. Query Param
|
|
70
|
+
resp = client.get("/?lang=en")
|
|
71
|
+
assert resp.text == "Lang: en - Msg: Hello"
|
|
72
|
+
# Query param sets the cookie
|
|
73
|
+
assert "voodoo_lang=en" in resp.headers.get("set-cookie", "")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import asyncio
|
|
3
|
+
from voodoo.queue import queue, enqueue, start_workers, stop_workers
|
|
4
|
+
|
|
5
|
+
processed_items = []
|
|
6
|
+
|
|
7
|
+
@queue("test_queue")
|
|
8
|
+
async def process_item(payload):
|
|
9
|
+
processed_items.append(payload)
|
|
10
|
+
|
|
11
|
+
@queue("sync_queue")
|
|
12
|
+
def process_sync_item(payload):
|
|
13
|
+
processed_items.append(f"sync-{payload}")
|
|
14
|
+
|
|
15
|
+
@pytest.mark.asyncio
|
|
16
|
+
async def test_queue_workers():
|
|
17
|
+
processed_items.clear()
|
|
18
|
+
|
|
19
|
+
# Enqueue items
|
|
20
|
+
await enqueue("test_queue", "item1")
|
|
21
|
+
await enqueue("test_queue", "item2")
|
|
22
|
+
await enqueue("sync_queue", "item3")
|
|
23
|
+
|
|
24
|
+
# Start workers
|
|
25
|
+
await start_workers()
|
|
26
|
+
|
|
27
|
+
# Allow workers to process the queue
|
|
28
|
+
await asyncio.sleep(0.1)
|
|
29
|
+
|
|
30
|
+
# Stop workers
|
|
31
|
+
await stop_workers()
|
|
32
|
+
|
|
33
|
+
assert "item1" in processed_items
|
|
34
|
+
assert "item2" in processed_items
|
|
35
|
+
assert "sync-item3" in processed_items
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from voodoo.theme import Theme, ThemeColors, set_theme, default_theme
|
|
3
|
+
|
|
4
|
+
def test_default_theme():
|
|
5
|
+
assert default_theme.mode == "dark"
|
|
6
|
+
assert default_theme.colors.primary == "#007AFF"
|
|
7
|
+
|
|
8
|
+
def test_custom_theme():
|
|
9
|
+
custom_colors = ThemeColors(primary="#FF0000", extra={"custom": "#00FF00"})
|
|
10
|
+
theme = Theme(mode="light", colors=custom_colors)
|
|
11
|
+
|
|
12
|
+
assert theme.mode == "light"
|
|
13
|
+
assert theme.colors.primary == "#FF0000"
|
|
14
|
+
|
|
15
|
+
css_vars = theme.to_css_variables()
|
|
16
|
+
assert "--color-primary: #FF0000;" in css_vars
|
|
17
|
+
assert "--color-custom: #00FF00;" in css_vars
|
|
18
|
+
|
|
19
|
+
tw_config = theme.to_tailwind_config()
|
|
20
|
+
assert '"primary": "#FF0000"' in tw_config
|
|
21
|
+
assert '"custom": "#00FF00"' in tw_config
|
|
22
|
+
|
|
23
|
+
def test_set_theme():
|
|
24
|
+
original_theme = default_theme
|
|
25
|
+
try:
|
|
26
|
+
new_theme = Theme(mode="light")
|
|
27
|
+
set_theme(new_theme)
|
|
28
|
+
|
|
29
|
+
# In voodoo.theme namespace, default_theme should be updated
|
|
30
|
+
import voodoo.theme
|
|
31
|
+
assert voodoo.theme.default_theme.mode == "light"
|
|
32
|
+
finally:
|
|
33
|
+
set_theme(original_theme)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import json
|
|
3
|
+
from voodoo.core import register_event, ws_manager
|
|
4
|
+
|
|
5
|
+
def test_websocket_event(client):
|
|
6
|
+
received = []
|
|
7
|
+
|
|
8
|
+
async def async_dummy_handler(element_id, value):
|
|
9
|
+
received.append((element_id, value))
|
|
10
|
+
await ws_manager.broadcast_patch("target-1", "<b>Updated</b>")
|
|
11
|
+
|
|
12
|
+
register_event("test_action", async_dummy_handler)
|
|
13
|
+
|
|
14
|
+
with client.websocket_connect("/_voodoo_ws") as websocket:
|
|
15
|
+
websocket.send_text(json.dumps({
|
|
16
|
+
"type": "event",
|
|
17
|
+
"event": "test_action",
|
|
18
|
+
"id": "btn-1",
|
|
19
|
+
"value": "clicked"
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
# Receive the patch response
|
|
23
|
+
response = websocket.receive_text()
|
|
24
|
+
data = json.loads(response)
|
|
25
|
+
|
|
26
|
+
assert data["type"] == "patch"
|
|
27
|
+
assert data["id"] == "target-1"
|
|
28
|
+
assert data["html"] == "<b>Updated</b>"
|
|
29
|
+
|
|
30
|
+
assert len(received) == 1
|
|
31
|
+
assert received[0] == ("btn-1", "clicked")
|
|
32
|
+
|
|
33
|
+
def test_websocket_append(client):
|
|
34
|
+
async def append_handler(element_id, value):
|
|
35
|
+
await ws_manager.broadcast_append("list-1", "<li>New Item</li>")
|
|
36
|
+
|
|
37
|
+
register_event("append_action", append_handler)
|
|
38
|
+
|
|
39
|
+
with client.websocket_connect("/_voodoo_ws") as websocket:
|
|
40
|
+
websocket.send_text(json.dumps({
|
|
41
|
+
"type": "event",
|
|
42
|
+
"event": "append_action",
|
|
43
|
+
"id": "add-btn",
|
|
44
|
+
"value": ""
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
response = websocket.receive_text()
|
|
48
|
+
data = json.loads(response)
|
|
49
|
+
|
|
50
|
+
assert data["type"] == "append"
|
|
51
|
+
assert data["id"] == "list-1"
|
|
52
|
+
assert data["html"] == "<li>New Item</li>"
|
|
@@ -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
|
+
]
|
|
@@ -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
|