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/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,4 @@
1
+ from acuity.runtime.storage._model import LakebaseModel
2
+ from acuity.runtime.storage._client import LakebaseClient
3
+
4
+ __all__ = ["LakebaseModel", "LakebaseClient"]
@@ -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