Reformatted, and some adjustments to how sessionize operates.
Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import collections
|
||||
import collections.abc
|
||||
import contextlib
|
||||
import datetime
|
||||
import functools
|
||||
import inspect
|
||||
@@ -15,15 +16,17 @@ import sqlalchemy.orm
|
||||
from playlist import enums
|
||||
from playlist import env
|
||||
from playlist import models
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
|
||||
# Initialize asynchronous logger
|
||||
logger = aiologger.Logger.with_default_handlers(name="sql_logger")
|
||||
|
||||
_engine = sqlalchemy.ext.asyncio.create_async_engine(env.DATABASE_URL, echo=True)
|
||||
_engine = create_async_engine(env.DATABASE_URL, echo=True)
|
||||
|
||||
_async_session_maker = sqlalchemy.orm.sessionmaker(
|
||||
_engine, class_=sqlalchemy.ext.asyncio.AsyncSession, expire_on_commit=False
|
||||
_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
@@ -33,7 +36,7 @@ class Sessionizable(typing.Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
*args: tuple[typing.Any, ...],
|
||||
session: sqlalchemy.ext.asyncio.AsyncSession = None,
|
||||
session: AsyncSession | None = None,
|
||||
**kwargs: dict[str, typing.Any],
|
||||
) -> (collections.abc.AsyncGenerator, collections.abc.Coroutine):
|
||||
"""The signature for a sessionizable function.
|
||||
@@ -52,72 +55,41 @@ class Sessionizable(typing.Protocol):
|
||||
...
|
||||
|
||||
|
||||
async def _validate_session_arg(name: str, kwargs) -> None:
|
||||
@contextlib.asynccontextmanager
|
||||
async def _db_logger(
|
||||
func_name: str, session: AsyncSession
|
||||
) -> collections.abc.AsyncGenerator[None, None]:
|
||||
try:
|
||||
if not (
|
||||
kwargs["session"] is None
|
||||
or isinstance(kwargs["session"], sqlalchemy.ext.asyncio.AsyncSession)
|
||||
):
|
||||
msg = f"{name} has an invalid session of type {type(kwargs['session'])}."
|
||||
await logger.error(msg)
|
||||
raise TypeError(msg)
|
||||
yield
|
||||
|
||||
except sqlalchemy.exc.SQLAlchemyError as e:
|
||||
await logger.exception(f"Database error in {func_name}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
await logger.exception(f"Unexpected error in {func_name}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def _validate_signature(func: Sessionizable) -> None:
|
||||
sig = inspect.signature(func)
|
||||
try:
|
||||
sig.parameters["session"]
|
||||
|
||||
except KeyError as e:
|
||||
msg = f"{name} does not contain a session keyword argument."
|
||||
await logger.error(msg)
|
||||
msg = f"{func.__name__} does not have a session parameter."
|
||||
raise UnboundLocalError(msg) from e
|
||||
|
||||
|
||||
def _coro_db_logger(func: Sessionizable) -> Sessionizable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper[
|
||||
**P
|
||||
](*args: P.args, **kwargs: P.kwargs) -> collections.abc.Coroutine:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
except sqlalchemy.exc.SQLAlchemyError as e:
|
||||
await logger.exception(f"Database error in {func.__name__}: {e}")
|
||||
await kwargs["session"].rollback()
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
await logger.exception(f"Unexpected error in {func.__name__}: {e}")
|
||||
await kwargs["session"].rollback()
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _async_gen_db_logger(func: Sessionizable) -> Sessionizable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper[
|
||||
**P
|
||||
](*args: P.args, **kwargs: P.kwargs) -> collections.abc.Coroutine:
|
||||
try:
|
||||
async for item in func(*args, **kwargs):
|
||||
yield item
|
||||
|
||||
except sqlalchemy.exc.SQLAlchemyError as e:
|
||||
await logger.exception(f"Database error in {func.__name__}: {e}")
|
||||
await kwargs["session"].rollback()
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
await logger.exception(f"Unexpected error in {func.__name__}: {e}")
|
||||
await kwargs["session"].rollback()
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def sessionize(func: Sessionizable) -> Sessionizable:
|
||||
"""Decorator that ensures a database session is available to an async function or generator.
|
||||
|
||||
This decorator automatically injects a `session` of type sqlalchemy.ext.asyncio.AsyncSession
|
||||
This decorator automatically injects a `session` of type AsyncSession
|
||||
into the decorated function if one is not provided. If the `session` keyword argument is
|
||||
missing or set to None, a new session is created using the _async_session_maker and passed to
|
||||
the function. If a session is already provided when the function is called, it uses the
|
||||
missing or set to None, a new session is created using the `_async_session_maker` and passed
|
||||
to the function. If a session is already provided when the function is called, it uses the
|
||||
existing session.
|
||||
|
||||
The decorator is intended for use with coroutine functions and async generator functions that
|
||||
@@ -144,7 +116,7 @@ def sessionize(func: Sessionizable) -> Sessionizable:
|
||||
|
||||
Example:
|
||||
@sessionize
|
||||
async def fetch_data(session: Optional[AsyncSession] = None):
|
||||
async def fetch_data(session: AsyncSession | None = None):
|
||||
# Function body using session
|
||||
...
|
||||
|
||||
@@ -158,6 +130,8 @@ def sessionize(func: Sessionizable) -> Sessionizable:
|
||||
- Sessionizable: The Protocol for sessionizable functions.
|
||||
- AsyncSession: SQLAlchemy class used for asynchronous session management.
|
||||
"""
|
||||
_validate_signature(func)
|
||||
|
||||
ret: Sessionizable
|
||||
match func:
|
||||
case func if inspect.iscoroutinefunction(func):
|
||||
@@ -166,14 +140,16 @@ def sessionize(func: Sessionizable) -> Sessionizable:
|
||||
async def _coro_wrapper[
|
||||
**P
|
||||
](*args: P.args, **kwargs: P.kwargs) -> collections.abc.Coroutine:
|
||||
await _validate_session_arg(func.__name__, kwargs)
|
||||
logged_func = _coro_db_logger(func)
|
||||
|
||||
"""Wrap a sessionized coroutine function to inject the session if needed."""
|
||||
if kwargs["session"] is None:
|
||||
async with _async_session_maker() as kwargs["session"]:
|
||||
return await logged_func(*args, **kwargs)
|
||||
async with (
|
||||
_async_session_maker() as kwargs["session"],
|
||||
_db_logger(func.__name__, kwargs["session"]),
|
||||
):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return await logged_func(*args, **kwargs)
|
||||
async with _db_logger(func.__name__, kwargs["session"]):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
ret = _coro_wrapper
|
||||
|
||||
@@ -184,16 +160,17 @@ def sessionize(func: Sessionizable) -> Sessionizable:
|
||||
**P
|
||||
](*args: P.args, **kwargs: P.kwargs) -> collections.abc.AsyncGenerator:
|
||||
"""Wrap a sessionized async generator function to inject the session if needed."""
|
||||
await _validate_session_arg(func.__name__, kwargs)
|
||||
|
||||
logged_func = _async_gen_db_logger(func)
|
||||
if kwargs["session"] is None:
|
||||
async with _async_session_maker() as kwargs["session"]:
|
||||
async for element in logged_func(*args, **kwargs):
|
||||
async with (
|
||||
_async_session_maker() as kwargs["session"],
|
||||
_db_logger(func.__name__, kwargs["session"]),
|
||||
):
|
||||
async for element in func(*args, **kwargs):
|
||||
yield element
|
||||
else:
|
||||
async for element in logged_func(*args, **kwargs):
|
||||
yield element
|
||||
async with _db_logger(func.__name__, kwargs["session"]):
|
||||
async for element in func(*args, **kwargs):
|
||||
yield element
|
||||
|
||||
ret = _asyncgen_wrapper
|
||||
|
||||
@@ -232,7 +209,7 @@ async def drop_db() -> None:
|
||||
async def insert_or_update_track(
|
||||
track_data: dict[str, typing.Any],
|
||||
*,
|
||||
session: sqlalchemy.ext.asyncio.AsyncSession = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Insert a new track or update an existing one in the database asynchronously.
|
||||
|
||||
@@ -263,9 +240,7 @@ async def insert_or_update_track(
|
||||
|
||||
|
||||
@sessionize
|
||||
async def cleanup_unused_genres(
|
||||
*, session: sqlalchemy.ext.asyncio.AsyncSession = None
|
||||
) -> None:
|
||||
async def cleanup_unused_genres(*, session: AsyncSession | None = None) -> None:
|
||||
"""Remove genres that are no longer used by any tracks.
|
||||
|
||||
Keyword Args:
|
||||
@@ -292,7 +267,7 @@ async def set_timestamp(
|
||||
event: enums.Event,
|
||||
operation: enums.TimedEvent,
|
||||
*,
|
||||
session: sqlalchemy.ext.asyncio.AsyncSession = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> datetime.datetime:
|
||||
"""Mark the start or end timestamps in the statistics model.
|
||||
|
||||
@@ -344,7 +319,7 @@ async def set_timestamp(
|
||||
|
||||
@sessionize
|
||||
async def remove_holiday_by_id(
|
||||
holiday_id: int, *, session: sqlalchemy.ext.asyncio.AsyncSession = None
|
||||
holiday_id: int, *, session: AsyncSession | None = None
|
||||
) -> None:
|
||||
"""Remove a holiday from the database by ID.
|
||||
|
||||
@@ -383,7 +358,7 @@ async def update_statistics(
|
||||
new_max_holiday_favorite_base: int,
|
||||
new_max_holiday_general_base: int,
|
||||
*,
|
||||
session: sqlalchemy.ext.asyncio.AsyncSession = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Update the statistics model.
|
||||
|
||||
@@ -462,7 +437,7 @@ async def fetch_tracks_for_sublist(
|
||||
limit: int,
|
||||
playlist_entries: set[int],
|
||||
*,
|
||||
session: sqlalchemy.ext.asyncio.AsyncSession = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> collections.abc.AsyncGenerator[models.Track, None]:
|
||||
"""Fetch tracks for a given sublist type within a category.
|
||||
|
||||
@@ -518,7 +493,7 @@ async def insert_into_playlist( # noqa: C901
|
||||
sublist: enums.Sublist,
|
||||
is_favorite: bool,
|
||||
*,
|
||||
session: sqlalchemy.ext.asyncio.AsyncSession = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Insert a track into the playlist.
|
||||
|
||||
@@ -552,7 +527,7 @@ async def insert_into_playlist( # noqa: C901
|
||||
|
||||
@sessionize
|
||||
async def gen_all_category_ids(
|
||||
*, session: sqlalchemy.ext.asyncio.AsyncSession = None
|
||||
*, session: AsyncSession | None = None
|
||||
) -> collections.abc.AsyncGenerator[int, None, None]:
|
||||
"""Generate the current category ids from the database.
|
||||
|
||||
@@ -570,7 +545,7 @@ async def gen_all_category_ids(
|
||||
|
||||
@sessionize
|
||||
async def gen_active_holiday_ids(
|
||||
*, session: sqlalchemy.ext.asyncio.AsyncSession = None
|
||||
*, session: AsyncSession | None = None
|
||||
) -> collections.abc.AsyncGenerator[int, None, None]:
|
||||
"""Generate the current active holiday ids from the database.
|
||||
|
||||
@@ -589,7 +564,7 @@ async def gen_active_holiday_ids(
|
||||
@sessionize
|
||||
async def get_existing_playlist_track_info(
|
||||
*,
|
||||
session: sqlalchemy.ext.asyncio.AsyncSession = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> tuple[set[int], dict[tuple[bool, int, bool, enums.Sublist], int]]:
|
||||
"""Get the existing playlist track information.
|
||||
|
||||
@@ -623,9 +598,7 @@ async def get_existing_playlist_track_info(
|
||||
|
||||
|
||||
@sessionize
|
||||
async def get_statistics(
|
||||
*, session: sqlalchemy.ext.asyncio.AsyncSession = None
|
||||
) -> models.Statistics:
|
||||
async def get_statistics(*, session: AsyncSession | None = None) -> models.Statistics:
|
||||
"""Get the statistics object for the database.
|
||||
|
||||
Keyword Args:
|
||||
@@ -641,7 +614,7 @@ async def get_statistics(
|
||||
|
||||
@sessionize
|
||||
async def remove_track_from_playlist(
|
||||
server_id: str, *, session: sqlalchemy.ext.asyncio.AsyncSession = None
|
||||
server_id: str, *, session: AsyncSession | None = None
|
||||
):
|
||||
"""Directly removes all entries from PlaylistTrack that are associated with a given server_id.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user