Skip to content

Commit 211b47a

Browse files
authored
Merge pull request #1 from Open-LLM-VTuber/dev
Dev
2 parents dd64764 + 414be91 commit 211b47a

16 files changed

Lines changed: 770 additions & 64 deletions

File tree

alembic.ini

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[alembic]
2+
script_location = bot_sdk/db/migrations
3+
prepend_sys_path = .
4+
5+
[loggers]
6+
keys = root,sqlalchemy,alembic
7+
8+
[handlers]
9+
keys = console
10+
11+
[formatters]
12+
keys = generic
13+
14+
[logger_root]
15+
level = WARN
16+
handlers = console
17+
18+
[logger_sqlalchemy]
19+
level = WARN
20+
handlers = console
21+
qualname = sqlalchemy.engine
22+
23+
[logger_alembic]
24+
level = INFO
25+
handlers = console
26+
qualname = alembic
27+
28+
[handler_console]
29+
class = StreamHandler
30+
args = (sys.stdout,)
31+
level = NOTSET
32+
formatter = generic
33+
34+
[formatter_generic]
35+
format = %(levelname)-5.5s [%(name)s] %(message)s

bot_sdk/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .runner import BotRunner
1515
from .logging import setup_logging
1616
from .storage import BotStorage, CachedStorage
17+
from .config import StorageConfig
1718
from .models import (
1819
Event,
1920
EventsResponse,
@@ -29,6 +30,18 @@
2930
UpdatePresenceRequest,
3031
GetUserGroupsRequest,
3132
GetUserGroupsResponse,
33+
DataModel,
34+
Timestamped,
35+
IDModel,
36+
SoftDelete,
37+
)
38+
from .db import (
39+
Base,
40+
make_sqlite_url,
41+
create_engine,
42+
create_sessionmaker,
43+
session_scope,
44+
AsyncRepository,
3245
)
3346

3447
__all__ = [
@@ -45,6 +58,7 @@
4558
"setup_logging",
4659
"BotStorage",
4760
"CachedStorage",
61+
"StorageConfig",
4862
"Event",
4963
"EventsResponse",
5064
"Message",
@@ -61,4 +75,14 @@
6175
"GetUserGroupsResponse",
6276
"Validator",
6377
"OptionValidator",
78+
"DataModel",
79+
"Timestamped",
80+
"IDModel",
81+
"SoftDelete",
82+
"Base",
83+
"make_sqlite_url",
84+
"create_engine",
85+
"create_sessionmaker",
86+
"session_scope",
87+
"AsyncRepository",
6488
]

bot_sdk/bot.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .commands import CommandInvocation, CommandParser, CommandSpec, CommandArgument
1212
from .storage import BotStorage
1313
from .permissions import PermissionPolicy
14-
from .config import BotLocalConfig, load_bot_local_config, save_bot_local_config
14+
from .config import BotLocalConfig, StorageConfig, load_bot_local_config, save_bot_local_config
1515
from .models import (
1616
Event,
1717
Message,
@@ -37,6 +37,7 @@ class BaseBot(abc.ABC):
3737
# Storage configuration
3838
enable_storage = True
3939
storage_path: Optional[str] = None # Defaults to "bot_data/{bot_name}.db"
40+
storage_config: Optional[StorageConfig] = None
4041

4142
def __init__(self, client: AsyncClient) -> None:
4243
self.client = client
@@ -54,6 +55,10 @@ def __init__(self, client: AsyncClient) -> None:
5455
def set_runner(self, runner: "BotRunner") -> None:
5556
"""Called by BotRunner to allow commands to signal runner actions."""
5657
self._runner = runner
58+
59+
def set_storage_config(self, storage_config: Optional[StorageConfig]) -> None:
60+
"""Inject per-bot storage configuration from runner/config layer."""
61+
self.storage_config = storage_config
5762

5863
async def post_init(self) -> None:
5964
"""Hook for post-initialization logic. Override if needed.
@@ -76,9 +81,14 @@ async def _init_storage(self) -> None:
7681
return
7782
if self.storage_path is None:
7883
self.storage_path = f"bot_data/{bot_name}.db"
84+
auto_cfg = self.storage_config or StorageConfig()
7985
self.storage = BotStorage(
8086
db_path=self.storage_path,
81-
namespace=f"bot_{bot_name}"
87+
namespace=f"bot_{bot_name}",
88+
auto_cache=auto_cfg.auto_cache,
89+
auto_flush_interval=auto_cfg.auto_flush_interval,
90+
auto_flush_retry=auto_cfg.auto_flush_retry,
91+
auto_flush_max_retries=auto_cfg.auto_flush_max_retries,
8292
)
8393
logger.info(f"Initialized storage at {self.storage_path}")
8494
# Initialize permissions helper

bot_sdk/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@
77
from pathlib import Path
88

99

10+
class StorageConfig(BaseModel):
11+
"""Per-bot storage options for KV backend.
12+
13+
auto_cache: enable always-on cache with periodic flush
14+
auto_flush_interval: seconds between flush attempts
15+
auto_flush_retry: retry delay after a flush failure (e.g., DB locked)
16+
auto_flush_max_retries: max retries per key per flush cycle
17+
"""
18+
19+
auto_cache: bool = False
20+
auto_flush_interval: float = 5.0
21+
auto_flush_retry: float = 1.0
22+
auto_flush_max_retries: int = 3
23+
24+
1025
class BotConfig(BaseModel):
1126
name: str
1227
module: Optional[str] = None
@@ -15,6 +30,7 @@ class BotConfig(BaseModel):
1530
zuliprc: Optional[str] = None
1631
event_types: list[str] = Field(default_factory=lambda: ["message"])
1732
config: dict[str, Any] = Field(default_factory=dict)
33+
storage: Optional[StorageConfig] = None
1834

1935

2036
class AppConfig(BaseModel):
@@ -66,6 +82,7 @@ def save_bot_local_config(path: str | Path, config: BotLocalConfig) -> None:
6682
__all__ = [
6783
"AppConfig",
6884
"BotConfig",
85+
"StorageConfig",
6986
"load_config",
7087
"BotLocalConfig",
7188
"load_bot_local_config",

bot_sdk/db/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from .database import (
2+
Base,
3+
make_sqlite_url,
4+
create_engine,
5+
create_sessionmaker,
6+
session_scope,
7+
)
8+
from .repository import AsyncRepository
9+
10+
__all__ = [
11+
"Base",
12+
"make_sqlite_url",
13+
"create_engine",
14+
"create_sessionmaker",
15+
"session_scope",
16+
"AsyncRepository",
17+
]

bot_sdk/db/database.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
from contextlib import asynccontextmanager
4+
from pathlib import Path
5+
from typing import AsyncIterator
6+
7+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
8+
from sqlalchemy.orm import DeclarativeBase
9+
10+
__all__ = [
11+
"Base",
12+
"make_sqlite_url",
13+
"create_engine",
14+
"create_sessionmaker",
15+
"session_scope",
16+
]
17+
18+
19+
class Base(DeclarativeBase):
20+
"""Declarative base for ORM models."""
21+
22+
23+
def make_sqlite_url(db_path: str | Path) -> str:
24+
"""Build an async SQLite URL using aiosqlite driver."""
25+
path = Path(db_path).expanduser().resolve()
26+
return f"sqlite+aiosqlite:///{path.as_posix()}"
27+
28+
29+
def create_engine(url: str, *, echo: bool = False) -> AsyncEngine:
30+
"""Create an async SQLAlchemy engine.
31+
32+
Defaults are tuned for SQLite; adjust pool settings if swapping drivers.
33+
"""
34+
return create_async_engine(url, echo=echo, future=True)
35+
36+
37+
def create_sessionmaker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
38+
"""Return an async sessionmaker bound to the given engine."""
39+
return async_sessionmaker(engine, expire_on_commit=False)
40+
41+
42+
@asynccontextmanager
43+
async def session_scope(session_factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[AsyncSession]:
44+
"""Async context manager that yields a session and commits/rolls back safely."""
45+
session = session_factory()
46+
try:
47+
yield session
48+
await session.commit()
49+
except Exception:
50+
await session.rollback()
51+
raise
52+
finally:
53+
await session.close()

bot_sdk/db/migrations/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Migrations layout
2+
3+
- SDK-owned migrations live here and should only cover shared/SDK tables.
4+
- Bot-specific schemas should keep their own Alembic env/migrations under each bot directory (e.g., `bots/my_bot/migrations`).
5+
- Point `alembic.ini` in each bot to its own `script_location`; the SDK env in this folder remains for shared models only.

bot_sdk/db/migrations/env.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import os
5+
from logging.config import fileConfig
6+
from pathlib import Path
7+
8+
from alembic import context
9+
from sqlalchemy import pool
10+
from sqlalchemy.engine import Connection
11+
from sqlalchemy.ext.asyncio import async_engine_from_config
12+
13+
from bot_sdk.db.database import Base, make_sqlite_url
14+
15+
config = context.config
16+
if config.config_file_name is not None:
17+
fileConfig(config.config_file_name)
18+
19+
# Metadata for autogenerate
20+
# Add ORM models to Base to include them in migrations
21+
22+
_target_metadata = Base.metadata
23+
24+
25+
def get_url() -> str:
26+
env_url = os.getenv("DATABASE_URL")
27+
if env_url:
28+
return env_url
29+
default_path = Path("bot_data/db.sqlite").resolve()
30+
return make_sqlite_url(default_path)
31+
32+
33+
def run_migrations_offline() -> None:
34+
url = get_url()
35+
context.configure(
36+
url=url,
37+
target_metadata=_target_metadata,
38+
literal_binds=True,
39+
dialect_opts={"paramstyle": "named"},
40+
)
41+
42+
with context.begin_transaction():
43+
context.run_migrations()
44+
45+
46+
def do_run_migrations(connection: Connection) -> None:
47+
context.configure(connection=connection, target_metadata=_target_metadata)
48+
49+
with context.begin_transaction():
50+
context.run_migrations()
51+
52+
53+
def run_migrations_online() -> None:
54+
configuration = config.get_section(config.config_ini_section) or {}
55+
configuration["sqlalchemy.url"] = get_url()
56+
57+
connectable = async_engine_from_config(
58+
configuration,
59+
prefix="sqlalchemy.",
60+
poolclass=pool.NullPool,
61+
future=True,
62+
)
63+
64+
async def run() -> None:
65+
async with connectable.connect() as connection:
66+
await connection.run_sync(do_run_migrations)
67+
68+
asyncio.run(run())
69+
70+
71+
def run_migrations() -> None:
72+
if context.is_offline_mode():
73+
run_migrations_offline()
74+
else:
75+
run_migrations_online()
76+
77+
78+
run_migrations()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Initial empty migration placeholder.
2+
3+
Revision ID: 0001_initial
4+
Revises:
5+
Create Date: 2026-01-10
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "0001_initial"
15+
down_revision = None
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade() -> None:
21+
# Add initial tables here when introducing ORM models.
22+
pass
23+
24+
25+
def downgrade() -> None:
26+
# Drop initial tables here when introducing ORM models.
27+
pass

bot_sdk/db/repository.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
from typing import Generic, Iterable, Optional, Sequence, TypeVar
4+
5+
from sqlalchemy import Select, delete, select
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
8+
ModelT = TypeVar("ModelT")
9+
10+
__all__ = ["AsyncRepository"]
11+
12+
13+
class AsyncRepository(Generic[ModelT]):
14+
"""Lightweight base repository with common CRUD helpers.
15+
16+
Subclass and set ``model`` to your ORM mapped class.
17+
Methods open their own unit-of-work on the provided session.
18+
"""
19+
20+
model: type[ModelT]
21+
22+
def __init__(self, session: AsyncSession):
23+
self.session = session
24+
25+
async def get(self, pk: object) -> Optional[ModelT]:
26+
return await self.session.get(self.model, pk)
27+
28+
async def add(self, instance: ModelT) -> ModelT:
29+
self.session.add(instance)
30+
await self.session.flush()
31+
await self.session.refresh(instance)
32+
return instance
33+
34+
async def delete(self, pk: object) -> bool:
35+
instance = await self.get(pk)
36+
if instance is None:
37+
return False
38+
await self.session.delete(instance)
39+
await self.session.flush()
40+
return True
41+
42+
async def list(self, *, limit: int = 100, offset: int = 0) -> Sequence[ModelT]:
43+
stmt: Select[ModelT] = select(self.model).limit(limit).offset(offset)
44+
rows = await self.session.execute(stmt)
45+
return rows.scalars().all()

0 commit comments

Comments
 (0)