belgie-alchemy 0.1.0a4__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.
- belgie_alchemy/__init__.py +33 -0
- belgie_alchemy/__tests__/__init__.py +0 -0
- belgie_alchemy/__tests__/adapter/__init__.py +0 -0
- belgie_alchemy/__tests__/adapter/test_adapter.py +493 -0
- belgie_alchemy/__tests__/auth_models/__init__.py +0 -0
- belgie_alchemy/__tests__/auth_models/test_auth_models.py +91 -0
- belgie_alchemy/__tests__/base/__init__.py +0 -0
- belgie_alchemy/__tests__/base/test_base.py +90 -0
- belgie_alchemy/__tests__/conftest.py +39 -0
- belgie_alchemy/__tests__/fixtures/__init__.py +12 -0
- belgie_alchemy/__tests__/fixtures/database.py +38 -0
- belgie_alchemy/__tests__/fixtures/models.py +119 -0
- belgie_alchemy/__tests__/mixins/__init__.py +0 -0
- belgie_alchemy/__tests__/mixins/test_mixins.py +80 -0
- belgie_alchemy/__tests__/settings/__init__.py +0 -0
- belgie_alchemy/__tests__/settings/test_settings.py +342 -0
- belgie_alchemy/__tests__/settings/test_settings_integration.py +416 -0
- belgie_alchemy/__tests__/types/__init__.py +0 -0
- belgie_alchemy/__tests__/types/test_types.py +155 -0
- belgie_alchemy/adapter.py +323 -0
- belgie_alchemy/base.py +25 -0
- belgie_alchemy/mixins.py +83 -0
- belgie_alchemy/py.typed +0 -0
- belgie_alchemy/settings.py +146 -0
- belgie_alchemy/types.py +32 -0
- belgie_alchemy-0.1.0a4.dist-info/METADATA +266 -0
- belgie_alchemy-0.1.0a4.dist-info/RECORD +28 -0
- belgie_alchemy-0.1.0a4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from tempfile import TemporaryDirectory
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sqlalchemy import Integer, event, select
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
9
|
+
|
|
10
|
+
from belgie_alchemy.__tests__.fixtures.models import User
|
|
11
|
+
from belgie_alchemy.base import NAMING_CONVENTION, Base
|
|
12
|
+
from belgie_alchemy.types import DateTimeUTC
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_type_annotation_map_uses_datetimeutc() -> None:
|
|
16
|
+
mapping = Base.type_annotation_map
|
|
17
|
+
assert mapping[datetime] is DateTimeUTC
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_datetime_annotation_auto_uses_datetimeutc() -> None:
|
|
21
|
+
"""Verify that Mapped[datetime] automatically uses DateTimeUTC without explicit column type."""
|
|
22
|
+
|
|
23
|
+
class TestModel(Base):
|
|
24
|
+
__tablename__ = "test_auto_datetime"
|
|
25
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, init=False)
|
|
26
|
+
# No explicit DateTimeUTC type specified - should be inferred from type_annotation_map
|
|
27
|
+
timestamp: Mapped[datetime]
|
|
28
|
+
|
|
29
|
+
timestamp_column = TestModel.__table__.c.timestamp # type: ignore[attr-defined]
|
|
30
|
+
assert isinstance(timestamp_column.type, DateTimeUTC)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_naming_convention_applied() -> None:
|
|
34
|
+
assert Base.metadata.naming_convention == NAMING_CONVENTION
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_dataclass_kw_only_init() -> None:
|
|
38
|
+
user = User(email="a@b.com")
|
|
39
|
+
assert user.email == "a@b.com"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_file_based_sqlite_database() -> None:
|
|
44
|
+
"""Test that models work correctly with file-based SQLite database."""
|
|
45
|
+
with TemporaryDirectory() as tmpdir:
|
|
46
|
+
db_path = Path(tmpdir) / "test.db"
|
|
47
|
+
db_url = f"sqlite+aiosqlite:///{db_path}"
|
|
48
|
+
|
|
49
|
+
# Create engine with file-based database
|
|
50
|
+
engine = create_async_engine(db_url, echo=False)
|
|
51
|
+
|
|
52
|
+
# Enable foreign keys for SQLite
|
|
53
|
+
@event.listens_for(engine.sync_engine, "connect")
|
|
54
|
+
def _enable_fk(dbapi_conn, _connection_record) -> None:
|
|
55
|
+
cursor = dbapi_conn.cursor()
|
|
56
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
57
|
+
cursor.close()
|
|
58
|
+
|
|
59
|
+
# Create tables
|
|
60
|
+
async with engine.begin() as conn:
|
|
61
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
62
|
+
|
|
63
|
+
# Create session factory
|
|
64
|
+
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
65
|
+
|
|
66
|
+
# Test basic operations
|
|
67
|
+
async with session_factory() as session:
|
|
68
|
+
# Create user
|
|
69
|
+
user = User(email="file_db_test@example.com", name="Test User")
|
|
70
|
+
session.add(user)
|
|
71
|
+
await session.commit()
|
|
72
|
+
|
|
73
|
+
user_id = user.id
|
|
74
|
+
|
|
75
|
+
# Verify persistence by reading in new session
|
|
76
|
+
async with session_factory() as session:
|
|
77
|
+
result = await session.execute(select(User).where(User.id == user_id))
|
|
78
|
+
retrieved_user = result.scalar_one()
|
|
79
|
+
|
|
80
|
+
assert retrieved_user.email == "file_db_test@example.com"
|
|
81
|
+
assert retrieved_user.name == "Test User"
|
|
82
|
+
assert retrieved_user.created_at is not None
|
|
83
|
+
assert retrieved_user.created_at.tzinfo is UTC
|
|
84
|
+
|
|
85
|
+
# Cleanup
|
|
86
|
+
await engine.dispose()
|
|
87
|
+
|
|
88
|
+
# Verify database file was created
|
|
89
|
+
assert db_path.exists()
|
|
90
|
+
assert db_path.stat().st_size > 0
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Pytest fixtures for alchemy tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import pytest_asyncio
|
|
8
|
+
|
|
9
|
+
from belgie_alchemy.__tests__.fixtures.database import get_test_engine, get_test_session_factory
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import AsyncGenerator
|
|
13
|
+
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest_asyncio.fixture
|
|
18
|
+
async def alchemy_engine() -> AsyncGenerator[AsyncEngine, None]:
|
|
19
|
+
"""Create an isolated in-memory SQLite engine for testing.
|
|
20
|
+
|
|
21
|
+
Each test gets its own in-memory database, so Base.metadata.create_all
|
|
22
|
+
is safe even when tests run in parallel - there's no shared state.
|
|
23
|
+
"""
|
|
24
|
+
engine = await get_test_engine()
|
|
25
|
+
yield engine
|
|
26
|
+
await engine.dispose()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest_asyncio.fixture
|
|
30
|
+
async def alchemy_session_factory(alchemy_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
|
31
|
+
return await get_test_session_factory(alchemy_engine)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest_asyncio.fixture
|
|
35
|
+
async def alchemy_session(
|
|
36
|
+
alchemy_session_factory: async_sessionmaker[AsyncSession],
|
|
37
|
+
) -> AsyncGenerator[AsyncSession, None]:
|
|
38
|
+
async with alchemy_session_factory() as session:
|
|
39
|
+
yield session
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from belgie_alchemy.__tests__.fixtures.database import get_test_db, get_test_engine, get_test_session_factory
|
|
2
|
+
from belgie_alchemy.__tests__.fixtures.models import Account, OAuthState, Session, User
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"Account",
|
|
6
|
+
"OAuthState",
|
|
7
|
+
"Session",
|
|
8
|
+
"User",
|
|
9
|
+
"get_test_db",
|
|
10
|
+
"get_test_engine",
|
|
11
|
+
"get_test_session_factory",
|
|
12
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import event
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
5
|
+
|
|
6
|
+
from belgie_alchemy import Base
|
|
7
|
+
|
|
8
|
+
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def get_test_engine() -> AsyncEngine:
|
|
12
|
+
engine = create_async_engine(
|
|
13
|
+
TEST_DATABASE_URL,
|
|
14
|
+
echo=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
@event.listens_for(engine.sync_engine, "connect")
|
|
18
|
+
def _enable_foreign_keys(dbapi_conn, _connection_record) -> None:
|
|
19
|
+
cursor = dbapi_conn.cursor()
|
|
20
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
21
|
+
cursor.close()
|
|
22
|
+
|
|
23
|
+
async with engine.begin() as conn:
|
|
24
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
25
|
+
return engine
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def get_test_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
|
29
|
+
return async_sessionmaker(
|
|
30
|
+
engine,
|
|
31
|
+
class_=AsyncSession,
|
|
32
|
+
expire_on_commit=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def get_test_db(session_factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession, None]:
|
|
37
|
+
async with session_factory() as session:
|
|
38
|
+
yield session
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Test models for alchemy tests.
|
|
2
|
+
|
|
3
|
+
Defines concrete auth models for testing. These mirror the examples in
|
|
4
|
+
examples/alchemy/auth_models.py and demonstrate how users would define
|
|
5
|
+
their own models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime # noqa: TC003
|
|
11
|
+
from uuid import UUID # noqa: TC003
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import JSON, ForeignKey, Text, UniqueConstraint
|
|
14
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
15
|
+
|
|
16
|
+
from belgie_alchemy import Base, DateTimeUTC, PrimaryKeyMixin, TimestampMixin
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class User(Base, PrimaryKeyMixin, TimestampMixin):
|
|
20
|
+
"""Test User model."""
|
|
21
|
+
|
|
22
|
+
__tablename__ = "users"
|
|
23
|
+
|
|
24
|
+
email: Mapped[str] = mapped_column(unique=True, index=True)
|
|
25
|
+
email_verified: Mapped[bool] = mapped_column(default=False)
|
|
26
|
+
name: Mapped[str | None] = mapped_column(default=None)
|
|
27
|
+
image: Mapped[str | None] = mapped_column(default=None)
|
|
28
|
+
scopes: Mapped[list[str] | None] = mapped_column(JSON, default=None)
|
|
29
|
+
custom_field: Mapped[str | None] = mapped_column(default=None)
|
|
30
|
+
|
|
31
|
+
accounts: Mapped[list[Account]] = relationship(
|
|
32
|
+
back_populates="user",
|
|
33
|
+
cascade="all, delete-orphan",
|
|
34
|
+
init=False,
|
|
35
|
+
)
|
|
36
|
+
sessions: Mapped[list[Session]] = relationship(
|
|
37
|
+
back_populates="user",
|
|
38
|
+
cascade="all, delete-orphan",
|
|
39
|
+
init=False,
|
|
40
|
+
)
|
|
41
|
+
oauth_states: Mapped[list[OAuthState]] = relationship(
|
|
42
|
+
back_populates="user",
|
|
43
|
+
cascade="all, delete-orphan",
|
|
44
|
+
init=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Account(Base, PrimaryKeyMixin, TimestampMixin):
|
|
49
|
+
"""Test Account model."""
|
|
50
|
+
|
|
51
|
+
__tablename__ = "accounts"
|
|
52
|
+
|
|
53
|
+
user_id: Mapped[UUID] = mapped_column(
|
|
54
|
+
ForeignKey("users.id", ondelete="cascade", onupdate="cascade"),
|
|
55
|
+
nullable=False,
|
|
56
|
+
)
|
|
57
|
+
provider: Mapped[str] = mapped_column(Text)
|
|
58
|
+
provider_account_id: Mapped[str] = mapped_column(Text)
|
|
59
|
+
access_token: Mapped[str | None] = mapped_column(default=None)
|
|
60
|
+
refresh_token: Mapped[str | None] = mapped_column(default=None)
|
|
61
|
+
expires_at: Mapped[datetime | None] = mapped_column(DateTimeUTC, default=None)
|
|
62
|
+
token_type: Mapped[str | None] = mapped_column(default=None)
|
|
63
|
+
scope: Mapped[str | None] = mapped_column(default=None)
|
|
64
|
+
id_token: Mapped[str | None] = mapped_column(default=None)
|
|
65
|
+
|
|
66
|
+
user: Mapped[User] = relationship(
|
|
67
|
+
back_populates="accounts",
|
|
68
|
+
lazy="selectin",
|
|
69
|
+
init=False,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
__table_args__ = (
|
|
73
|
+
UniqueConstraint(
|
|
74
|
+
"provider",
|
|
75
|
+
"provider_account_id",
|
|
76
|
+
name="uq_accounts_provider_provider_account_id",
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Session(Base, PrimaryKeyMixin, TimestampMixin):
|
|
82
|
+
"""Test Session model."""
|
|
83
|
+
|
|
84
|
+
__tablename__ = "sessions"
|
|
85
|
+
|
|
86
|
+
user_id: Mapped[UUID] = mapped_column(
|
|
87
|
+
ForeignKey("users.id", ondelete="cascade", onupdate="cascade"),
|
|
88
|
+
nullable=False,
|
|
89
|
+
)
|
|
90
|
+
expires_at: Mapped[datetime] = mapped_column(DateTimeUTC)
|
|
91
|
+
ip_address: Mapped[str | None] = mapped_column(default=None)
|
|
92
|
+
user_agent: Mapped[str | None] = mapped_column(default=None)
|
|
93
|
+
|
|
94
|
+
user: Mapped[User] = relationship(
|
|
95
|
+
back_populates="sessions",
|
|
96
|
+
lazy="selectin",
|
|
97
|
+
init=False,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OAuthState(Base, PrimaryKeyMixin, TimestampMixin):
|
|
102
|
+
"""Test OAuthState model."""
|
|
103
|
+
|
|
104
|
+
__tablename__ = "oauth_states"
|
|
105
|
+
|
|
106
|
+
state: Mapped[str] = mapped_column(unique=True, index=True)
|
|
107
|
+
user_id: Mapped[UUID | None] = mapped_column(
|
|
108
|
+
ForeignKey("users.id", ondelete="set null", onupdate="cascade"),
|
|
109
|
+
nullable=True,
|
|
110
|
+
)
|
|
111
|
+
expires_at: Mapped[datetime] = mapped_column(DateTimeUTC)
|
|
112
|
+
code_verifier: Mapped[str | None] = mapped_column(default=None)
|
|
113
|
+
redirect_url: Mapped[str | None] = mapped_column(default=None)
|
|
114
|
+
|
|
115
|
+
user: Mapped[User] | None = relationship(
|
|
116
|
+
back_populates="oauth_states",
|
|
117
|
+
lazy="selectin",
|
|
118
|
+
init=False,
|
|
119
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from sqlalchemy.exc import IntegrityError
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from belgie_alchemy.__tests__.fixtures.models import User
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_primary_key_mixin_defaults() -> None:
|
|
11
|
+
id_column = User.__table__.c.id # type: ignore[attr-defined]
|
|
12
|
+
assert id_column.primary_key
|
|
13
|
+
assert str(id_column.server_default.arg) == "gen_random_uuid()"
|
|
14
|
+
assert id_column.index
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_primary_key_client_side_generation() -> None:
|
|
18
|
+
"""Test that UUID is generated client-side via default_factory."""
|
|
19
|
+
user1 = User(email="user1@example.com")
|
|
20
|
+
user2 = User(email="user2@example.com")
|
|
21
|
+
|
|
22
|
+
# UUIDs should be generated automatically
|
|
23
|
+
assert isinstance(user1.id, UUID)
|
|
24
|
+
assert isinstance(user2.id, UUID)
|
|
25
|
+
|
|
26
|
+
# UUIDs should be unique
|
|
27
|
+
assert user1.id != user2.id
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
async def test_primary_key_persists_client_generated_uuid(alchemy_session: AsyncSession) -> None:
|
|
32
|
+
"""Test that client-generated UUIDs are persisted correctly."""
|
|
33
|
+
user = User(email="persist@example.com")
|
|
34
|
+
original_id = user.id
|
|
35
|
+
|
|
36
|
+
alchemy_session.add(user)
|
|
37
|
+
await alchemy_session.commit()
|
|
38
|
+
|
|
39
|
+
# Refresh from database
|
|
40
|
+
await alchemy_session.refresh(user)
|
|
41
|
+
|
|
42
|
+
# UUID should be unchanged
|
|
43
|
+
assert user.id == original_id
|
|
44
|
+
assert isinstance(user.id, UUID)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_primary_key_unique_constraint(alchemy_session: AsyncSession) -> None:
|
|
49
|
+
"""Test that duplicate UUIDs are rejected by unique constraint."""
|
|
50
|
+
user1 = User(email="user1@example.com")
|
|
51
|
+
alchemy_session.add(user1)
|
|
52
|
+
await alchemy_session.commit()
|
|
53
|
+
|
|
54
|
+
# Save the ID and expunge user1 to avoid identity conflicts
|
|
55
|
+
user1_id = user1.id
|
|
56
|
+
alchemy_session.expunge(user1)
|
|
57
|
+
|
|
58
|
+
# Try to create another user with the same ID
|
|
59
|
+
user2 = User(email="user2@example.com")
|
|
60
|
+
user2.id = user1_id # Force same ID
|
|
61
|
+
|
|
62
|
+
alchemy_session.add(user2)
|
|
63
|
+
with pytest.raises(IntegrityError):
|
|
64
|
+
await alchemy_session.commit()
|
|
65
|
+
|
|
66
|
+
await alchemy_session.rollback()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_timestamp_mixin_defaults() -> None:
|
|
70
|
+
user = User(email="defaults@example.com")
|
|
71
|
+
assert user.created_at is not None
|
|
72
|
+
assert user.updated_at is not None
|
|
73
|
+
assert user.deleted_at is None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_mark_deleted_sets_timestamp() -> None:
|
|
77
|
+
user = User(email="x@example.com")
|
|
78
|
+
assert user.deleted_at is None
|
|
79
|
+
user.mark_deleted()
|
|
80
|
+
assert user.deleted_at is not None
|
|
File without changes
|