acUIty-core 0.2.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.
- acuity/__init__.py +81 -0
- acuity/__main__.py +19 -0
- acuity/app/__init__.py +5 -0
- acuity/app/datalets/__init__.py +4 -0
- acuity/app/datalets/_chat_container.py +3 -0
- acuity/app/datalets/_empty.py +4 -0
- acuity/commands.py +43 -0
- acuity/datalets/_Mock.py +15 -0
- acuity/datalets/__init__.py +0 -0
- acuity/datalets/cards/_CardGrid.py +20 -0
- acuity/datalets/cards/__init__.py +6 -0
- acuity/datalets/cards/_model.py +18 -0
- acuity/datalets/form/_Form.py +143 -0
- acuity/datalets/form/__init__.py +10 -0
- acuity/datalets/form/_markers.py +82 -0
- acuity/datalets/form/_model.py +10 -0
- acuity/model/__init__.py +6 -0
- acuity/model/_agen_resp_chunk.py +7 -0
- acuity/model/_app.py +104 -0
- acuity/model/_datalet.py +77 -0
- acuity/model/_lucide.py +1671 -0
- acuity/model/_page.py +68 -0
- acuity/model/_panel.py +24 -0
- acuity/runtime/__init__.py +14 -0
- acuity/runtime/_ai_router.py +84 -0
- acuity/runtime/_datalet_router.py +108 -0
- acuity/runtime/_engine.py +144 -0
- acuity/runtime/_stream_router.py +60 -0
- acuity/runtime/storage/__init__.py +4 -0
- acuity/runtime/storage/_client.py +132 -0
- acuity/runtime/storage/_model.py +54 -0
- acuity/runtime/stream/__init__.py +1 -0
- acuity/runtime/stream/_stream_handler.py +40 -0
- acuity/runtime/templates/index.html +10 -0
- acuity/scripts/build_web_static.py +15 -0
- acuity_core-0.2.0.dist-info/METADATA +41 -0
- acuity_core-0.2.0.dist-info/RECORD +39 -0
- acuity_core-0.2.0.dist-info/WHEEL +4 -0
- acuity_core-0.2.0.dist-info/entry_points.txt +3 -0
acuity/model/_page.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from acuity.model._datalet import _Datalet
|
|
6
|
+
from acuity.model._lucide import LucideIconName
|
|
7
|
+
from acuity.model._panel import _Panel
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Page(BaseModel):
|
|
12
|
+
"""Page class represents a page in the application UI.
|
|
13
|
+
Handles page configuration and properties.
|
|
14
|
+
"""
|
|
15
|
+
page_id: str
|
|
16
|
+
title: str
|
|
17
|
+
route_path: str
|
|
18
|
+
icon: LucideIconName
|
|
19
|
+
panels: list[_Panel]
|
|
20
|
+
|
|
21
|
+
def __init__(self, page_id: str, title: str = "", route_path: str = "", icon : LucideIconName = "Box"):
|
|
22
|
+
"""Initialize a new Page instance.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
name: The name of the page
|
|
26
|
+
title: The title displayed in the menu (defaults to name if not provided)
|
|
27
|
+
route_path: The URL route path for the page (defaults to /name if not provided)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Validate name
|
|
31
|
+
if page_id != page_id.lower():
|
|
32
|
+
raise ValueError(f"Page id must be lowercase: '{page_id}'")
|
|
33
|
+
|
|
34
|
+
if ' ' in page_id:
|
|
35
|
+
raise ValueError(f"Page id cannot contain spaces: '{page_id}'")
|
|
36
|
+
|
|
37
|
+
if not re.match(r'^[a-z_\-\.][a-z0-9_\-\.]*$', page_id):
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Page id can only contain letters, numbers, '_', '-', '.', and cannot start with a number: '{page_id}'")
|
|
40
|
+
|
|
41
|
+
# set default values
|
|
42
|
+
if not title:
|
|
43
|
+
title = page_id
|
|
44
|
+
if not route_path:
|
|
45
|
+
route_path = f"/{page_id}"
|
|
46
|
+
|
|
47
|
+
super().__init__(page_id=page_id, title=title, route_path=route_path, icon=icon, panels=[])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def panel(self, width: str = "full", height: str = "full") -> Callable[[Callable], Callable]:
|
|
51
|
+
"""Decorator that registers a panel to this page.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
width: width of the panel
|
|
55
|
+
height: height of the panel
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A decorator function that registers the panel and returns the original function
|
|
59
|
+
"""
|
|
60
|
+
def decorator(func: Callable[[], _Datalet]) -> Callable:
|
|
61
|
+
new_panel = _Panel(width=width, height=height)
|
|
62
|
+
datalet = func()
|
|
63
|
+
new_panel.id = datalet.id
|
|
64
|
+
new_panel.datalet = datalet.datalet_type
|
|
65
|
+
self.panels.append(new_panel)
|
|
66
|
+
return func
|
|
67
|
+
|
|
68
|
+
return decorator
|
acuity/model/_panel.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Literal, Optional
|
|
3
|
+
import uuid
|
|
4
|
+
from ._datalet import _DataletType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _Panel(BaseModel):
|
|
8
|
+
id: Optional[str]
|
|
9
|
+
width: Literal["full", "half"]
|
|
10
|
+
height: Literal["full", "fit"]
|
|
11
|
+
datalet: Optional[_DataletType] = None
|
|
12
|
+
variant: Literal["default", "brand", "highlight", "gosth"]
|
|
13
|
+
|
|
14
|
+
def __init__(self, width: str = "full", height: str = "full", title: str = "", variant: str = "default"):
|
|
15
|
+
"""Initialize a new Panel instance.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
width: The width of the panel ("full" or "half")
|
|
19
|
+
height: The height of the panel ("full" or "fit")
|
|
20
|
+
datalet: The datalet within the panel
|
|
21
|
+
"""
|
|
22
|
+
panel_id = f"panel_{uuid.uuid4().hex[:8]}"
|
|
23
|
+
super().__init__(id=panel_id, title=title, width=width, height=height, variant=variant)
|
|
24
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
## runtime
|
|
2
|
+
from acuity.runtime._engine import Engine
|
|
3
|
+
|
|
4
|
+
engine = Engine()
|
|
5
|
+
|
|
6
|
+
def run(file_path: str, port: int = 8000):
|
|
7
|
+
"""Run a Python file as a acuity application."""
|
|
8
|
+
engine.run(file_path, port=port)
|
|
9
|
+
|
|
10
|
+
def watch(file_path: str, port: int = 8000):
|
|
11
|
+
"""Run a Python file as a acuity application in development mode with file watching."""
|
|
12
|
+
engine.watch(file_path, port=port)
|
|
13
|
+
|
|
14
|
+
__all__ = ["run", "watch"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from openai.types.responses import ResponseTextDeltaEvent, ResponseOutputItemDoneEvent, ResponseOutputMessage, ResponseCompletedEvent, ResponseFunctionToolCall
|
|
2
|
+
from fastapi import APIRouter, Request
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from openai import OpenAI
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from sse_starlette.sse import EventSourceResponse, ServerSentEvent
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
ai_router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChatMessage(BaseModel):
|
|
16
|
+
role: str
|
|
17
|
+
content: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChatPayload(BaseModel):
|
|
21
|
+
prompt: str
|
|
22
|
+
history: list[ChatMessage] = []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@ai_router.post("/invoke/{model_name}")
|
|
26
|
+
async def ai_invoke(model_name: str, request: Request):
|
|
27
|
+
payload = ChatPayload(**(await request.json()))
|
|
28
|
+
|
|
29
|
+
messages = [{"role": msg.role, "content": msg.content} for msg in payload.history]
|
|
30
|
+
messages.append({"role": "user", "content": payload.prompt})
|
|
31
|
+
|
|
32
|
+
def event_generator():
|
|
33
|
+
|
|
34
|
+
base_url = "https://1276122062834378.8.gcp.databricks.com/serving-endpoints"
|
|
35
|
+
model_name = "mas-e00a6b00-endpoint"
|
|
36
|
+
|
|
37
|
+
token = os.environ.get("DATABRICKS_TOKEN")
|
|
38
|
+
logger.debug("DATABRICKS_TOKEN is %s", "set" if token else "not set")
|
|
39
|
+
|
|
40
|
+
client = OpenAI(
|
|
41
|
+
api_key=token,
|
|
42
|
+
base_url=f"{base_url}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
logger.info("Sending request to %s/%s/invocations for model %s...", base_url, model_name, model_name)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
|
|
49
|
+
with client.responses.create(
|
|
50
|
+
model=model_name,
|
|
51
|
+
input=messages,
|
|
52
|
+
stream=True,
|
|
53
|
+
extra_body={
|
|
54
|
+
"databricks_options": {
|
|
55
|
+
"return_trace": True
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
) as response:
|
|
59
|
+
|
|
60
|
+
for chunk in response:
|
|
61
|
+
logger.debug("Chunk: %s", chunk)
|
|
62
|
+
if isinstance(chunk, ResponseTextDeltaEvent):
|
|
63
|
+
event_type = "delta"
|
|
64
|
+
elif isinstance(chunk, ResponseOutputItemDoneEvent) and isinstance(chunk.item, ResponseOutputMessage):
|
|
65
|
+
if chunk.item.content[0].text.startswith("<name>"):
|
|
66
|
+
event_type = "agent_router_message"
|
|
67
|
+
else:
|
|
68
|
+
event_type = "output_message"
|
|
69
|
+
elif isinstance(chunk, ResponseOutputItemDoneEvent) and isinstance(chunk.item, ResponseFunctionToolCall):
|
|
70
|
+
event_type = "function_call"
|
|
71
|
+
elif isinstance(chunk, ResponseCompletedEvent):
|
|
72
|
+
event_type = "completed"
|
|
73
|
+
logger.info("Completed event received")
|
|
74
|
+
|
|
75
|
+
else:
|
|
76
|
+
event_type = "other"
|
|
77
|
+
|
|
78
|
+
yield ServerSentEvent(data=json.dumps(chunk.model_dump()), event=event_type)
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error("Error during request: %s", e, exc_info=True)
|
|
82
|
+
yield ServerSentEvent(data=json.dumps({"error": str(e)}), event="error")
|
|
83
|
+
|
|
84
|
+
return EventSourceResponse(event_generator())
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from openai.types.responses import ResponseTextDeltaEvent, ResponseOutputItemDoneEvent, ResponseOutputMessage, ResponseCompletedEvent, ResponseFunctionToolCall
|
|
2
|
+
from fastapi import APIRouter, Request
|
|
3
|
+
from openai import AsyncOpenAI
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from sse_starlette.sse import EventSourceResponse, ServerSentEvent
|
|
7
|
+
import os
|
|
8
|
+
from acuity.app import app
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
datalet_router = APIRouter()
|
|
13
|
+
|
|
14
|
+
@datalet_router.get("/{datalet_id}/config")
|
|
15
|
+
async def datalet_config(datalet_id: str, request: Request):
|
|
16
|
+
datalet = app.datalet_by_id(datalet_id)
|
|
17
|
+
if datalet is not None:
|
|
18
|
+
return datalet.metadata()
|
|
19
|
+
else:
|
|
20
|
+
return {"error": "Datalet not found"}
|
|
21
|
+
|
|
22
|
+
@datalet_router.get("/{datalet_id}/data")
|
|
23
|
+
async def datalet_data_fetch(datalet_id: str, request: Request):
|
|
24
|
+
datalet = app.datalet_by_id(datalet_id)
|
|
25
|
+
if datalet is not None:
|
|
26
|
+
return datalet.fetch_data()
|
|
27
|
+
else:
|
|
28
|
+
return {"error": "Datalet not found"}
|
|
29
|
+
|
|
30
|
+
@datalet_router.get("/{datalet_id}/data/{item_id}/fetch")
|
|
31
|
+
async def datalet_data_item_fetch(datalet_id: str, item_id: str, request: Request):
|
|
32
|
+
datalet = app.datalet_by_id(datalet_id)
|
|
33
|
+
if datalet is not None:
|
|
34
|
+
try:
|
|
35
|
+
handler = datalet.fetch_item_data(item_id)
|
|
36
|
+
except RuntimeError as e:
|
|
37
|
+
return {"error": str(e)}
|
|
38
|
+
return handler()
|
|
39
|
+
else:
|
|
40
|
+
return {"error": "Datalet not found"}
|
|
41
|
+
|
|
42
|
+
@datalet_router.post("/{datalet_id}/data/synch")
|
|
43
|
+
async def datalet_data_synch(datalet_id: str, request: Request):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@datalet_router.post("/{datalet_id}/data/stream")
|
|
47
|
+
async def datalet_data_stream(datalet_id: str, request: Request):
|
|
48
|
+
|
|
49
|
+
async def event_generator():
|
|
50
|
+
|
|
51
|
+
body = await request.json()
|
|
52
|
+
logger.info("Received question: %s", body["userPrompt"])
|
|
53
|
+
|
|
54
|
+
base_url = "https://1276122062834378.8.gcp.databricks.com/serving-endpoints"
|
|
55
|
+
model_name = "mas-e00a6b00-endpoint"
|
|
56
|
+
|
|
57
|
+
token = os.environ.get("DATABRICKS_TOKEN")
|
|
58
|
+
logger.debug("DATABRICKS_TOKEN is %s", "set" if token else "not set")
|
|
59
|
+
|
|
60
|
+
client = AsyncOpenAI(
|
|
61
|
+
api_key=token,
|
|
62
|
+
base_url=f"{base_url}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger.info("Sending request to %s/%s/invocations for model %s...", base_url, model_name, model_name)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
|
|
69
|
+
response = await client.responses.create(
|
|
70
|
+
model=model_name,
|
|
71
|
+
input=[
|
|
72
|
+
{
|
|
73
|
+
"role": "user",
|
|
74
|
+
"content": body["userPrompt"]
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
stream=True,
|
|
78
|
+
extra_body={
|
|
79
|
+
"databricks_options": {
|
|
80
|
+
"return_trace": True
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async for chunk in response:
|
|
86
|
+
logger.debug("Chunk: %s", chunk)
|
|
87
|
+
if isinstance(chunk, ResponseTextDeltaEvent):
|
|
88
|
+
event_type = "delta"
|
|
89
|
+
elif isinstance(chunk, ResponseOutputItemDoneEvent) and isinstance(chunk.item, ResponseOutputMessage):
|
|
90
|
+
if chunk.item.content[0].text.startswith("<name>"):
|
|
91
|
+
event_type = "agent_router_message"
|
|
92
|
+
else:
|
|
93
|
+
event_type = "output_message"
|
|
94
|
+
elif isinstance(chunk, ResponseOutputItemDoneEvent) and isinstance(chunk.item, ResponseFunctionToolCall):
|
|
95
|
+
event_type = "function_call"
|
|
96
|
+
elif isinstance(chunk, ResponseCompletedEvent):
|
|
97
|
+
event_type = "completed"
|
|
98
|
+
logger.info("Completed event received")
|
|
99
|
+
else:
|
|
100
|
+
event_type = "other"
|
|
101
|
+
|
|
102
|
+
yield ServerSentEvent(data=json.dumps(chunk.model_dump()), event=event_type)
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.error("Error during request: %s", e, exc_info=True)
|
|
106
|
+
yield ServerSentEvent(data=json.dumps({"error": str(e)}), event="error")
|
|
107
|
+
|
|
108
|
+
return EventSourceResponse(event_generator())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import logging
|
|
4
|
+
import uvicorn
|
|
5
|
+
import importlib.util
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
|
10
|
+
from starlette.exceptions import HTTPException
|
|
11
|
+
from starlette.datastructures import Headers
|
|
12
|
+
from starlette.responses import Response
|
|
13
|
+
from starlette.types import Scope
|
|
14
|
+
from fastapi.templating import Jinja2Templates
|
|
15
|
+
from acuity.app import app
|
|
16
|
+
from watchfiles import run_process
|
|
17
|
+
from acuity.model._app import _ImageType
|
|
18
|
+
from acuity.runtime._ai_router import ai_router
|
|
19
|
+
from acuity.runtime._datalet_router import datalet_router
|
|
20
|
+
from acuity.runtime._stream_router import events_router
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
def _run_app(file_path: str, port: int):
|
|
25
|
+
"""
|
|
26
|
+
Helper function to run the acuity application.
|
|
27
|
+
This is used by watchfiles.run_process to support auto-reload.
|
|
28
|
+
"""
|
|
29
|
+
engine = Engine()
|
|
30
|
+
engine.run(file_path, port=port)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- Static Files Setup ---
|
|
34
|
+
package_root = Path(__file__).parent
|
|
35
|
+
static_dir = os.path.join(package_root, "static")
|
|
36
|
+
os.makedirs(static_dir, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
# Mounted last: it matches every path, so it would shadow the API routers and
|
|
39
|
+
# the /mas mount above.
|
|
40
|
+
|
|
41
|
+
class Engine:
|
|
42
|
+
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self.router_app = FastAPI()
|
|
45
|
+
self.router_app.include_router(router=ai_router, prefix="/ai")
|
|
46
|
+
self.router_app.include_router(router=datalet_router, prefix="/datalet")
|
|
47
|
+
self.router_app.include_router(router=events_router, prefix="/stream")
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def _provision_app_routes(self):
|
|
51
|
+
@self.router_app.get("/app/config")
|
|
52
|
+
async def api_sample():
|
|
53
|
+
return app.model_dump()
|
|
54
|
+
|
|
55
|
+
@self.router_app.get("/page/{page_id}")
|
|
56
|
+
async def get_page(page_id: str):
|
|
57
|
+
if page_id in app._pages:
|
|
58
|
+
return app._pages[page_id].model_dump()
|
|
59
|
+
return {"error": "Page not found"}
|
|
60
|
+
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run_server(self, file_path: str, port: int = 8000):
|
|
66
|
+
|
|
67
|
+
self._provision_app_routes()
|
|
68
|
+
|
|
69
|
+
"""Run a Python file as a FastAPI application."""
|
|
70
|
+
spec = importlib.util.spec_from_file_location("app_module", file_path)
|
|
71
|
+
module = importlib.util.module_from_spec(spec)
|
|
72
|
+
spec.loader.exec_module(module)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Mount static files and templates
|
|
76
|
+
self.router_app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
77
|
+
templates = Jinja2Templates(directory=str(package_root / "templates"))
|
|
78
|
+
|
|
79
|
+
@self.router_app.get("/", response_class=HTMLResponse)
|
|
80
|
+
async def read_root(request: Request):
|
|
81
|
+
return templates.TemplateResponse("index.html", {
|
|
82
|
+
"title": app.title,
|
|
83
|
+
"request": request,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
# Determine the directory of the file being run
|
|
87
|
+
app_dir = Path(file_path).parent.resolve()
|
|
88
|
+
|
|
89
|
+
# image fetching route
|
|
90
|
+
@self.router_app.get("/image/generic/{image_name}")
|
|
91
|
+
async def fetch_images(image_name: Optional[str]):
|
|
92
|
+
image_path = app.image_registry()[image_name]
|
|
93
|
+
image_abs_path = app_dir / image_path
|
|
94
|
+
if image_abs_path.exists() and image_abs_path.suffix.lower() in [".png", ".svg"]:
|
|
95
|
+
return FileResponse(image_abs_path)
|
|
96
|
+
|
|
97
|
+
return {"error": "Image not found"}
|
|
98
|
+
|
|
99
|
+
# image fetching route
|
|
100
|
+
@self.router_app.get("/image/{image_type}")
|
|
101
|
+
async def fetch_images(image_type: _ImageType):
|
|
102
|
+
|
|
103
|
+
if image_type not in _ImageType or image_type == _ImageType.GENERIC:
|
|
104
|
+
return {"error": f"Invalid image type {image_type}"}
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
image_path = app.image_path(image_type)
|
|
108
|
+
except ValueError:
|
|
109
|
+
return {"error": f"Image type {image_type} not found"}
|
|
110
|
+
|
|
111
|
+
image_abs_path = app_dir / image_path
|
|
112
|
+
if image_abs_path.exists() and image_abs_path.suffix.lower() in [".png", ".svg"]:
|
|
113
|
+
return FileResponse(image_abs_path)
|
|
114
|
+
|
|
115
|
+
return {"error": "Image not found"}
|
|
116
|
+
|
|
117
|
+
# Register routes from the runtime
|
|
118
|
+
if hasattr(module, 'runtime'):
|
|
119
|
+
for path, handler in module.runtime.routes.items():
|
|
120
|
+
self.router_app.get(path)(handler)
|
|
121
|
+
|
|
122
|
+
# SPA fallback: browser navigations to any unknown path (client-side
|
|
123
|
+
# router pages) are served the "/" page. API/static clients that don't
|
|
124
|
+
# ask for HTML still get the regular 404.
|
|
125
|
+
@self.router_app.exception_handler(404)
|
|
126
|
+
async def spa_fallback(request: Request, exc: HTTPException):
|
|
127
|
+
if request.method == "GET" and "text/html" in request.headers.get("accept", ""):
|
|
128
|
+
return templates.TemplateResponse("index.html", {
|
|
129
|
+
"title": app.title,
|
|
130
|
+
"request": request,
|
|
131
|
+
})
|
|
132
|
+
return JSONResponse({"detail": exc.detail}, status_code=404)
|
|
133
|
+
|
|
134
|
+
# Run the application
|
|
135
|
+
uvicorn.run(self.router_app, host="0.0.0.0", port=port)
|
|
136
|
+
|
|
137
|
+
def run(self, file_path: str, port: int = 8000):
|
|
138
|
+
"""Run a Python file as a FastAPI application."""
|
|
139
|
+
self._run_server(file_path, port=port)
|
|
140
|
+
|
|
141
|
+
def watch(self, file_path: str, port: int = 8000):
|
|
142
|
+
"""Run a Python file as a FastAPI application in watch mode with auto-reload."""
|
|
143
|
+
run_process(Path(file_path).parent, target=_run_app, args=(file_path, port))
|
|
144
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from sse_starlette import EventSourceResponse
|
|
6
|
+
from sse_starlette.sse import EventSourceResponse, ServerSentEvent
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import random
|
|
10
|
+
from acuity.runtime.stream._stream_handler import registry
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
events_router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@events_router.get("/demo")
|
|
18
|
+
async def event_dispatcher(request: Request) -> EventSourceResponse:
|
|
19
|
+
def event_generator():
|
|
20
|
+
logger.info("Sending events ...")
|
|
21
|
+
try:
|
|
22
|
+
while True:
|
|
23
|
+
time.sleep(random.uniform(0.1, 1.0))
|
|
24
|
+
yield ServerSentEvent(data=json.dumps({"content": "dummy"}))
|
|
25
|
+
except Exception as e:
|
|
26
|
+
logger.error("Error during request: %s", e, exc_info=True)
|
|
27
|
+
yield ServerSentEvent(data=json.dumps({"error": str(e)}), event="error")
|
|
28
|
+
return EventSourceResponse(event_generator())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@events_router.get("/channel/{channel_name}")
|
|
32
|
+
async def channel_stream(channel_name: str, request: Request) -> EventSourceResponse:
|
|
33
|
+
ch = registry.get_or_create(channel_name)
|
|
34
|
+
|
|
35
|
+
if not ch:
|
|
36
|
+
return EventSourceResponse({"error": f"Channel {channel_name} not found"}, status_code=404)
|
|
37
|
+
|
|
38
|
+
async def event_generator():
|
|
39
|
+
q = ch._subscribe()
|
|
40
|
+
logger.info("Subscribing to channel %s", channel_name)
|
|
41
|
+
try:
|
|
42
|
+
while True:
|
|
43
|
+
if await request.is_disconnected():
|
|
44
|
+
logger.info("Request disconnected")
|
|
45
|
+
break
|
|
46
|
+
try:
|
|
47
|
+
logger.debug("Waiting for data on channel %s ...", channel_name)
|
|
48
|
+
data = await q.get()
|
|
49
|
+
logger.debug("Received data: %s", data)
|
|
50
|
+
yield ServerSentEvent(
|
|
51
|
+
data=json.dumps(data),
|
|
52
|
+
event=channel_name,
|
|
53
|
+
)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
yield ServerSentEvent(data=json.dumps({"error": str(e)}), event="error")
|
|
56
|
+
break
|
|
57
|
+
finally:
|
|
58
|
+
ch._unsubscribe(q)
|
|
59
|
+
|
|
60
|
+
return EventSourceResponse(event_generator())
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Sequence, Type, TypeVar
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
8
|
+
from databricks_ai_bridge.lakebase import AsyncLakebaseSQLAlchemy
|
|
9
|
+
from acuity.runtime.storage._model import LakebaseModel
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound=LakebaseModel)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LakebaseClient:
|
|
17
|
+
"""Async ORM client for Databricks Lakebase.
|
|
18
|
+
|
|
19
|
+
Wraps :class:`AsyncLakebaseSQLAlchemy` and exposes CRUD helpers that
|
|
20
|
+
operate on :class:`LakebaseModel` subclasses.
|
|
21
|
+
|
|
22
|
+
Example::
|
|
23
|
+
|
|
24
|
+
async with LakebaseClient(instance_name="my-lakebase") as db:
|
|
25
|
+
await db.initialize()
|
|
26
|
+
user = User(name="Alice", email="alice@example.com")
|
|
27
|
+
await db.create(user)
|
|
28
|
+
found = await db.get(User, user.id)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
instance_name: str | None = None,
|
|
35
|
+
autoscaling_endpoint: str | None = None,
|
|
36
|
+
project: str | None = None,
|
|
37
|
+
branch: str | None = None,
|
|
38
|
+
schema: str | None = None,
|
|
39
|
+
**engine_kwargs: Any,
|
|
40
|
+
) -> None:
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
self._lakebase = AsyncLakebaseSQLAlchemy(
|
|
44
|
+
instance_name=instance_name,
|
|
45
|
+
autoscaling_endpoint=autoscaling_endpoint,
|
|
46
|
+
project=project,
|
|
47
|
+
branch=branch,
|
|
48
|
+
schema=schema,
|
|
49
|
+
**engine_kwargs,
|
|
50
|
+
)
|
|
51
|
+
self._session_factory = async_sessionmaker(
|
|
52
|
+
self._lakebase.engine,
|
|
53
|
+
class_=AsyncSession,
|
|
54
|
+
expire_on_commit=False,
|
|
55
|
+
)
|
|
56
|
+
logger.info("LakebaseClient initialized")
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Lifecycle
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
async def initialize(self) -> None:
|
|
63
|
+
"""Create the schema (if configured) and all registered tables."""
|
|
64
|
+
await self._lakebase.create_schema()
|
|
65
|
+
async with self._lakebase.engine.begin() as conn:
|
|
66
|
+
await conn.run_sync(LakebaseModel.metadata.create_all)
|
|
67
|
+
logger.info("Database tables initialized")
|
|
68
|
+
|
|
69
|
+
async def close(self) -> None:
|
|
70
|
+
"""Dispose the underlying engine and release connections."""
|
|
71
|
+
await self._lakebase.engine.dispose()
|
|
72
|
+
logger.info("LakebaseClient closed")
|
|
73
|
+
|
|
74
|
+
async def __aenter__(self) -> LakebaseClient:
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
78
|
+
await self.close()
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# CRUD operations
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
async def create(self, obj: T) -> T:
|
|
86
|
+
"""Insert a new model instance and return it with server-set fields populated."""
|
|
87
|
+
async with self._session_factory() as session:
|
|
88
|
+
session.add(obj)
|
|
89
|
+
await session.commit()
|
|
90
|
+
await session.refresh(obj)
|
|
91
|
+
logger.debug("Created %s id=%s", type(obj).__name__, obj.id)
|
|
92
|
+
return obj
|
|
93
|
+
|
|
94
|
+
async def get(self, model_cls: Type[T], id: uuid.UUID) -> T | None:
|
|
95
|
+
"""Fetch a single record by primary key, or ``None`` if not found."""
|
|
96
|
+
async with self._session_factory() as session:
|
|
97
|
+
result = await session.get(model_cls, id)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
async def get_all(self, model_cls: Type[T], **filters: Any) -> Sequence[T]:
|
|
101
|
+
"""Fetch all records of *model_cls*, optionally filtered by column values.
|
|
102
|
+
|
|
103
|
+
Example::
|
|
104
|
+
|
|
105
|
+
users = await db.get_all(User, name="Alice")
|
|
106
|
+
"""
|
|
107
|
+
async with self._session_factory() as session:
|
|
108
|
+
stmt = select(model_cls)
|
|
109
|
+
for attr, value in filters.items():
|
|
110
|
+
stmt = stmt.where(getattr(model_cls, attr) == value)
|
|
111
|
+
result = await session.execute(stmt)
|
|
112
|
+
return result.scalars().all()
|
|
113
|
+
|
|
114
|
+
async def update(self, obj: T) -> T:
|
|
115
|
+
"""Merge changes from *obj* back into the database."""
|
|
116
|
+
async with self._session_factory() as session:
|
|
117
|
+
merged = await session.merge(obj)
|
|
118
|
+
await session.commit()
|
|
119
|
+
await session.refresh(merged)
|
|
120
|
+
logger.debug("Updated %s id=%s", type(obj).__name__, obj.id)
|
|
121
|
+
return merged
|
|
122
|
+
|
|
123
|
+
async def delete(self, model_cls: Type[T], id: uuid.UUID) -> bool:
|
|
124
|
+
"""Delete a record by primary key. Returns ``True`` if a row was deleted."""
|
|
125
|
+
async with self._session_factory() as session:
|
|
126
|
+
obj = await session.get(model_cls, id)
|
|
127
|
+
if obj is None:
|
|
128
|
+
return False
|
|
129
|
+
await session.delete(obj)
|
|
130
|
+
await session.commit()
|
|
131
|
+
logger.debug("Deleted %s id=%s", model_cls.__name__, id)
|
|
132
|
+
return True
|