diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 654676f..0f72f06 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -41,11 +41,11 @@ jobs: - name: Install dependencies with Poetry run: | - poetry install --no-interaction --with dev --without ML + poetry install --no-interaction --with dev --with db --without ML - name: Migrate DB run: | - alembic upgrade head + poetry run alembic upgrade head - name: Run tests and generate coverage report run: | diff --git a/.gitignore b/.gitignore index ebca630..972dcef 100644 --- a/.gitignore +++ b/.gitignore @@ -207,4 +207,6 @@ marimo/_lsp/ __marimo__/ #IDE -.idea \ No newline at end of file +.idea + +postgres_container.test.yml \ No newline at end of file diff --git a/alembic.ini b/alembic.ini index 035f57b..4eb87fb 100644 --- a/alembic.ini +++ b/alembic.ini @@ -84,7 +84,8 @@ path_separator = os # database URL. This is consumed by the user-maintained env.py script only. # other means of configuring database URLs may be customized within the env.py # file. -sqlalchemy.url = driver://user:pass@localhost/dbname + +sqlalchemy.url = ... [post_write_hooks] diff --git a/audiostats/db/api.py b/audiostats/db/api.py index 2b211e7..f30637d 100644 --- a/audiostats/db/api.py +++ b/audiostats/db/api.py @@ -2,22 +2,22 @@ import logging from collections.abc import Iterator -from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession -#from sqlalchemy.orm import sessionmaker, Session -from .uof import UnitOfWork +from .uow import UnitOfWork from audiostats.handlers import AlbumDTO +from audiostats.db.session import SessionFactory logger = logging.getLogger(__name__) class DBApi: - def __init__(self, session_factory : async_sessionmaker[AsyncSession]): - self._session_factory = session_factory + def __init__(self, db_url : str): + self._session_factory = SessionFactory(db_url) async def _upsert_album(self, album : AlbumDTO): - async with UnitOfWork(self._session_factory) as uow: - await uow.albums.upsert(album) + async with self._session_factory as sf: + async with UnitOfWork(sf()) as uow: + await uow.albums.upsert(album) async def upsert_albums(self, albums : Iterator[AlbumDTO]): batch = [] @@ -30,6 +30,7 @@ async def upsert_albums(self, albums : Iterator[AlbumDTO]): await asyncio.gather(*batch) async def get_all_albums(self): - async with UnitOfWork(self._session_factory) as uow: - return await uow.albums.all() + async with self._session_factory as sf: + async with UnitOfWork(sf()) as uow: + return await uow.albums.all() diff --git a/audiostats/db/repositories.py b/audiostats/db/repositories.py index 787a7d4..db099f7 100644 --- a/audiostats/db/repositories.py +++ b/audiostats/db/repositories.py @@ -45,7 +45,6 @@ async def find_by_title_performer(self, title : str, performer : str | None) -> selectinload(Album.tracks) )) return result.scalar_one_or_none() - #return self._session.query(Album).filter(Album.title == title and Album.performer == performer if performer else Album.performer.is_(None)).first() async def all(self) -> list[Album]: result = await self._session.scalars(select(Album).options( diff --git a/audiostats/db/session.py b/audiostats/db/session.py index e69de29..4fb838f 100644 --- a/audiostats/db/session.py +++ b/audiostats/db/session.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession + + +class SessionFactory: + def __init__(self, db_url : str): + self._engine = create_async_engine(url=db_url) + self._session_maker = async_sessionmaker(bind=self._engine) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self._engine.dispose() + + def __call__(self, *args, **kwargs) -> async_sessionmaker[AsyncSession]: + return self._session_maker \ No newline at end of file diff --git a/audiostats/db/uof.py b/audiostats/db/uow.py similarity index 95% rename from audiostats/db/uof.py rename to audiostats/db/uow.py index a1177e2..99c3516 100644 --- a/audiostats/db/uof.py +++ b/audiostats/db/uow.py @@ -1,5 +1,4 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -#from sqlalchemy.orm import sessionmaker, Session import logging from .repositories import AlbumRepository diff --git a/audiostats/db/worker.py b/audiostats/db/worker.py new file mode 100644 index 0000000..e69de29 diff --git a/config/config.toml b/config/config.toml index ae6dc54..e0916fd 100644 --- a/config/config.toml +++ b/config/config.toml @@ -7,4 +7,9 @@ '-show_entries', 'format=duration', '-of', 'default=nw=1:nokey=1', '{path_to_file}'] #the last arg here is the audio.ape filepath, you should use it as in example - \ No newline at end of file + +['DataBase'] + AsyncUrl = 'postgresql+asyncpg://user:pass@localhost:5433/audiostats' #DB url with async driver, used to exchange the data with db + SyncUrl = 'postgresql+psycopg2://user:pass@localhost:5433/audiostats' #DB url with sync driver, used for migrations + UserName = 'user' + PassWord = 'pass' \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py index 36112a3..5a2b077 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,3 +1,4 @@ +import os from logging.config import fileConfig from sqlalchemy import engine_from_config @@ -5,10 +6,19 @@ from alembic import context +from audiostats.db.models import Base + +from dotenv import load_dotenv + +load_dotenv() + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config +config.set_main_option('sqlalchemy.url', os.getenv('SYNC_DB_URL')) + + # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: @@ -18,7 +28,7 @@ # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -target_metadata = None +target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: diff --git a/migrations/versions/bd8d699fa622_album_and_track_tables_init.py b/migrations/versions/bd8d699fa622_album_and_track_tables_init.py new file mode 100644 index 0000000..7737093 --- /dev/null +++ b/migrations/versions/bd8d699fa622_album_and_track_tables_init.py @@ -0,0 +1,55 @@ +"""album and track tables init + +Revision ID: bd8d699fa622 +Revises: +Create Date: 2025-09-12 23:16:13.273814 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bd8d699fa622' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('albums', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=50), nullable=False), + sa.Column('performer', sa.String(length=50), nullable=True), + sa.Column('year', sa.Integer(), nullable=True), + sa.Column('path', sa.String(length=200), nullable=True), + sa.Column('cover', sa.String(length=200), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('performer', 'title', name='uq_album_performer_title') + ) + op.create_table('tracks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=50), nullable=False), + sa.Column('album_id', sa.Integer(), nullable=False), + sa.Column('number', sa.Integer(), nullable=True), + sa.Column('path', sa.String(length=200), nullable=True), + sa.Column('offset', sa.Float(), nullable=True), + sa.Column('duration', sa.Float(), nullable=True), + sa.ForeignKeyConstraint(['album_id'], ['albums.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tracks_album_id'), 'tracks', ['album_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_tracks_album_id'), table_name='tracks') + op.drop_table('tracks') + op.drop_table('albums') + # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock index a590dad..e107c66 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1037,6 +1037,26 @@ progress = ["tqdm (>=4.41.0,<5.0.0)"] sftp = ["paramiko (>=2.7.0)"] xxhash = ["xxhash (>=1.4.3)"] +[[package]] +name = "psycopg2" +version = "2.9.10" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716"}, + {file = "psycopg2-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:c6f7b8561225f9e711a9c47087388a97fdc948211c10a4bccbf0ba68ab7b3b5a"}, + {file = "psycopg2-2.9.10-cp311-cp311-win32.whl", hash = "sha256:47c4f9875125344f4c2b870e41b6aad585901318068acd01de93f3677a6522c2"}, + {file = "psycopg2-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4"}, + {file = "psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067"}, + {file = "psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e"}, + {file = "psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2"}, + {file = "psycopg2-2.9.10-cp39-cp39-win32.whl", hash = "sha256:9d5b3b94b79a844a986d029eee38998232451119ad653aea42bb9220a8c5066b"}, + {file = "psycopg2-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:88138c8dedcbfa96408023ea2b0c369eda40fe5d75002c0964c78f46f11fa442"}, + {file = "psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11"}, +] + [[package]] name = "pycparser" version = "2.22" @@ -1743,4 +1763,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "2ccef23cb56c079a1bc8855048a97a3a145f4ace881ea23b6e12333697c29ae4" +content-hash = "55f58d4277eb799072f2ec1f723ee933b55d7668342c2f6f110e1944c19e21b8" diff --git a/pyproject.toml b/pyproject.toml index b31d5ec..378c992 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "tomlcfg (>=0.1.0,<0.2.0)", "greenlet (>=3.2.4,<4.0.0)", "asyncpg (>=0.30.0,<0.31.0)", + "psycopg2 (>=2.9.10,<3.0.0)", ] [build-system] diff --git a/tests/test_db_api.py b/tests/test_db_api.py index 651ecf2..c2d4d2b 100644 --- a/tests/test_db_api.py +++ b/tests/test_db_api.py @@ -1,30 +1,60 @@ +import os + import pytest +import dotenv from audiostats.db.api import DBApi from audiostats.handlers import TrackDTO -from tests import test_session_factory, test_engine#, setup_database @pytest.mark.asyncio -async def test_upsert_albums(test_session_factory, processed_album_dtos): - session_factory = await test_session_factory - api = DBApi(session_factory) +async def test_async_upsert_albums(processed_album_dtos): + dotenv.load_dotenv() + db_url = os.getenv('ASYNC_DB_URL') + api = DBApi(db_url) await api.upsert_albums(processed_album_dtos) albums = await api.get_all_albums() - assert albums.sort(key=lambda x: x.title) == processed_album_dtos.sort(key=lambda x: x.title), 'Insert some albums to db' + assert albums.sort(key=lambda x: x.title) == processed_album_dtos.sort( + key=lambda x: x.title), 'Insert some albums to db' processed_album_dtos[0].tracks.pop(-1) processed_album_dtos[0].tracks.pop(2) - processed_album_dtos[1].tracks.append(TrackDTO(title='New_Track_1', number=6, path='music/new_track_1.flac', offset=None, duration=None)) + processed_album_dtos[1].tracks.append( + TrackDTO(title='New_Track_1', number=6, path='music/new_track_1.flac', offset=None, duration=None)) processed_album_dtos[1].year = None await api.upsert_albums(processed_album_dtos) albums = await api.get_all_albums() - assert albums.sort(key=lambda x: x.title) == processed_album_dtos.sort(key=lambda x: x.title), 'Update some albums in db' + assert albums.sort(key=lambda x: x.title) == processed_album_dtos.sort( + key=lambda x: x.title), 'Update some albums in db' + + + +# @pytest.mark.asyncio +# async def test_upsert_albums(test_session_factory, processed_album_dtos): +# session_factory = await test_session_factory +# api = DBApi(session_factory) +# +# await api.upsert_albums(processed_album_dtos) +# +# albums = await api.get_all_albums() +# +# assert albums.sort(key=lambda x: x.title) == processed_album_dtos.sort(key=lambda x: x.title), 'Insert some albums to db' +# +# processed_album_dtos[0].tracks.pop(-1) +# processed_album_dtos[0].tracks.pop(2) +# processed_album_dtos[1].tracks.append(TrackDTO(title='New_Track_1', number=6, path='music/new_track_1.flac', offset=None, duration=None)) +# processed_album_dtos[1].year = None +# +# await api.upsert_albums(processed_album_dtos) +# +# albums = await api.get_all_albums() +# +# assert albums.sort(key=lambda x: x.title) == processed_album_dtos.sort(key=lambda x: x.title), 'Update some albums in db'