belgie-alchemy 0.1.0__py3-none-any.whl → 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.
- belgie_alchemy/__init__.py +0 -26
- {belgie_alchemy-0.1.0.dist-info → belgie_alchemy-0.2.0.dist-info}/METADATA +43 -33
- belgie_alchemy-0.2.0.dist-info/RECORD +7 -0
- {belgie_alchemy-0.1.0.dist-info → belgie_alchemy-0.2.0.dist-info}/WHEEL +1 -1
- belgie_alchemy/__tests__/__init__.py +0 -0
- belgie_alchemy/__tests__/adapter/__init__.py +0 -0
- belgie_alchemy/__tests__/adapter/test_adapter.py +0 -493
- belgie_alchemy/__tests__/auth_models/__init__.py +0 -0
- belgie_alchemy/__tests__/auth_models/test_auth_models.py +0 -91
- belgie_alchemy/__tests__/base/__init__.py +0 -0
- belgie_alchemy/__tests__/base/test_base.py +0 -90
- belgie_alchemy/__tests__/conftest.py +0 -39
- belgie_alchemy/__tests__/fixtures/__init__.py +0 -12
- belgie_alchemy/__tests__/fixtures/database.py +0 -38
- belgie_alchemy/__tests__/fixtures/models.py +0 -119
- belgie_alchemy/__tests__/mixins/__init__.py +0 -0
- belgie_alchemy/__tests__/mixins/test_mixins.py +0 -80
- belgie_alchemy/__tests__/settings/__init__.py +0 -0
- belgie_alchemy/__tests__/settings/test_settings.py +0 -342
- belgie_alchemy/__tests__/settings/test_settings_integration.py +0 -416
- belgie_alchemy/__tests__/types/__init__.py +0 -0
- belgie_alchemy/__tests__/types/test_types.py +0 -155
- belgie_alchemy/base.py +0 -25
- belgie_alchemy/mixins.py +0 -83
- belgie_alchemy/types.py +0 -32
- belgie_alchemy-0.1.0.dist-info/RECORD +0 -28
|
@@ -1,416 +0,0 @@
|
|
|
1
|
-
"""Integration tests for DatabaseSettings with environment variables.
|
|
2
|
-
|
|
3
|
-
This module tests that DatabaseSettings can be correctly loaded from environment
|
|
4
|
-
variables using Pydantic's settings mechanism with separate prefixes.
|
|
5
|
-
|
|
6
|
-
Environment variable format (NO double underscores!):
|
|
7
|
-
- Type selector: BELGIE_DATABASE_TYPE=postgres or sqlite
|
|
8
|
-
- SQLite vars: BELGIE_SQLITE_DATABASE, BELGIE_SQLITE_ENABLE_FOREIGN_KEYS, etc.
|
|
9
|
-
- Postgres vars: BELGIE_POSTGRES_HOST, BELGIE_POSTGRES_PORT, BELGIE_POSTGRES_DATABASE, etc.
|
|
10
|
-
|
|
11
|
-
Examples:
|
|
12
|
-
# SQLite
|
|
13
|
-
BELGIE_DATABASE_TYPE=sqlite
|
|
14
|
-
BELGIE_SQLITE_DATABASE=:memory:
|
|
15
|
-
BELGIE_SQLITE_ENABLE_FOREIGN_KEYS=true
|
|
16
|
-
|
|
17
|
-
# PostgreSQL
|
|
18
|
-
BELGIE_DATABASE_TYPE=postgres
|
|
19
|
-
BELGIE_POSTGRES_HOST=localhost
|
|
20
|
-
BELGIE_POSTGRES_PORT=5432
|
|
21
|
-
BELGIE_POSTGRES_DATABASE=mydb
|
|
22
|
-
BELGIE_POSTGRES_USERNAME=user
|
|
23
|
-
BELGIE_POSTGRES_PASSWORD=pass
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
import os
|
|
27
|
-
from importlib.util import find_spec
|
|
28
|
-
from urllib.parse import urlparse
|
|
29
|
-
|
|
30
|
-
import pytest
|
|
31
|
-
from pydantic import ValidationError
|
|
32
|
-
from sqlalchemy import text
|
|
33
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
34
|
-
|
|
35
|
-
from belgie_alchemy.settings import DatabaseSettings
|
|
36
|
-
|
|
37
|
-
ASYNC_PG_AVAILABLE = find_spec("asyncpg") is not None
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
# ==================== SQLite Tests ====================
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@pytest.mark.integration
|
|
44
|
-
def test_sqlite_from_env_minimal(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
45
|
-
"""Test loading minimal SQLite configuration from environment variables."""
|
|
46
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
|
|
47
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
|
|
48
|
-
|
|
49
|
-
db = DatabaseSettings.from_env()
|
|
50
|
-
|
|
51
|
-
assert db.dialect.type == "sqlite"
|
|
52
|
-
assert db.dialect.database == ":memory:"
|
|
53
|
-
assert db.dialect.enable_foreign_keys is True # Default value
|
|
54
|
-
assert db.dialect.echo is False # Default value
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@pytest.mark.integration
|
|
58
|
-
def test_sqlite_from_env_full(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
59
|
-
"""Test loading full SQLite configuration from environment variables."""
|
|
60
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
|
|
61
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", "/tmp/test.db") # noqa: S108
|
|
62
|
-
monkeypatch.setenv("BELGIE_SQLITE_ENABLE_FOREIGN_KEYS", "false")
|
|
63
|
-
monkeypatch.setenv("BELGIE_SQLITE_ECHO", "true")
|
|
64
|
-
|
|
65
|
-
db = DatabaseSettings.from_env()
|
|
66
|
-
|
|
67
|
-
assert db.dialect.type == "sqlite"
|
|
68
|
-
assert db.dialect.database == "/tmp/test.db" # noqa: S108
|
|
69
|
-
assert db.dialect.enable_foreign_keys is False
|
|
70
|
-
assert db.dialect.echo is True
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@pytest.mark.asyncio
|
|
74
|
-
@pytest.mark.integration
|
|
75
|
-
async def test_sqlite_from_env_creates_working_engine(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
76
|
-
"""Test that SQLite engine created from env vars works correctly."""
|
|
77
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
|
|
78
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
|
|
79
|
-
|
|
80
|
-
db = DatabaseSettings.from_env()
|
|
81
|
-
|
|
82
|
-
# Test engine works
|
|
83
|
-
async with db.engine.connect() as conn:
|
|
84
|
-
result = await conn.execute(text("SELECT 1 as test"))
|
|
85
|
-
value = result.scalar_one()
|
|
86
|
-
assert value == 1
|
|
87
|
-
|
|
88
|
-
await db.engine.dispose()
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@pytest.mark.asyncio
|
|
92
|
-
@pytest.mark.integration
|
|
93
|
-
async def test_sqlite_from_env_foreign_keys_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
94
|
-
"""Test that foreign key enforcement from env vars works."""
|
|
95
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
|
|
96
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
|
|
97
|
-
monkeypatch.setenv("BELGIE_SQLITE_ENABLE_FOREIGN_KEYS", "true")
|
|
98
|
-
|
|
99
|
-
db = DatabaseSettings.from_env()
|
|
100
|
-
|
|
101
|
-
async with db.engine.connect() as conn:
|
|
102
|
-
result = await conn.execute(text("PRAGMA foreign_keys"))
|
|
103
|
-
value = result.scalar_one()
|
|
104
|
-
assert value == 1
|
|
105
|
-
|
|
106
|
-
await db.engine.dispose()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
@pytest.mark.integration
|
|
110
|
-
def test_sqlite_defaults_when_type_not_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
111
|
-
"""Test that SQLite is used when BELGIE_DATABASE_TYPE is not set."""
|
|
112
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
|
|
113
|
-
|
|
114
|
-
db = DatabaseSettings.from_env()
|
|
115
|
-
|
|
116
|
-
assert db.dialect.type == "sqlite"
|
|
117
|
-
assert db.dialect.database == ":memory:"
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# ==================== PostgreSQL Tests ====================
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
@pytest.mark.integration
|
|
124
|
-
def test_postgres_from_env_minimal(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
125
|
-
"""Test loading minimal PostgreSQL configuration from environment variables."""
|
|
126
|
-
if not ASYNC_PG_AVAILABLE:
|
|
127
|
-
pytest.skip("asyncpg not installed")
|
|
128
|
-
|
|
129
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
|
|
130
|
-
monkeypatch.setenv("BELGIE_POSTGRES_HOST", "localhost")
|
|
131
|
-
monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "testdb")
|
|
132
|
-
monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "testuser")
|
|
133
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "testpass")
|
|
134
|
-
|
|
135
|
-
db = DatabaseSettings.from_env()
|
|
136
|
-
|
|
137
|
-
assert db.dialect.type == "postgres"
|
|
138
|
-
assert db.dialect.host == "localhost"
|
|
139
|
-
assert db.dialect.port == 5432 # Default value
|
|
140
|
-
assert db.dialect.database == "testdb"
|
|
141
|
-
assert db.dialect.username == "testuser"
|
|
142
|
-
assert db.dialect.password.get_secret_value() == "testpass"
|
|
143
|
-
assert db.dialect.pool_size == 5 # Default value
|
|
144
|
-
assert db.dialect.max_overflow == 10 # Default value
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
@pytest.mark.integration
|
|
148
|
-
def test_postgres_from_env_full(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
149
|
-
"""Test loading full PostgreSQL configuration from environment variables."""
|
|
150
|
-
if not ASYNC_PG_AVAILABLE:
|
|
151
|
-
pytest.skip("asyncpg not installed")
|
|
152
|
-
|
|
153
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
|
|
154
|
-
monkeypatch.setenv("BELGIE_POSTGRES_HOST", "db.example.com")
|
|
155
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PORT", "5433")
|
|
156
|
-
monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "mydb")
|
|
157
|
-
monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "admin")
|
|
158
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "secret123")
|
|
159
|
-
monkeypatch.setenv("BELGIE_POSTGRES_POOL_SIZE", "20")
|
|
160
|
-
monkeypatch.setenv("BELGIE_POSTGRES_MAX_OVERFLOW", "30")
|
|
161
|
-
monkeypatch.setenv("BELGIE_POSTGRES_POOL_TIMEOUT", "60.0")
|
|
162
|
-
monkeypatch.setenv("BELGIE_POSTGRES_POOL_RECYCLE", "7200")
|
|
163
|
-
monkeypatch.setenv("BELGIE_POSTGRES_POOL_PRE_PING", "false")
|
|
164
|
-
monkeypatch.setenv("BELGIE_POSTGRES_ECHO", "true")
|
|
165
|
-
|
|
166
|
-
db = DatabaseSettings.from_env()
|
|
167
|
-
|
|
168
|
-
assert db.dialect.type == "postgres"
|
|
169
|
-
assert db.dialect.host == "db.example.com"
|
|
170
|
-
assert db.dialect.port == 5433
|
|
171
|
-
assert db.dialect.database == "mydb"
|
|
172
|
-
assert db.dialect.username == "admin"
|
|
173
|
-
assert db.dialect.password.get_secret_value() == "secret123"
|
|
174
|
-
assert db.dialect.pool_size == 20
|
|
175
|
-
assert db.dialect.max_overflow == 30
|
|
176
|
-
assert db.dialect.pool_timeout == 60.0
|
|
177
|
-
assert db.dialect.pool_recycle == 7200
|
|
178
|
-
assert db.dialect.pool_pre_ping is False
|
|
179
|
-
assert db.dialect.echo is True
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
@pytest.mark.asyncio
|
|
183
|
-
@pytest.mark.integration
|
|
184
|
-
async def test_postgres_from_env_actual_connection(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
185
|
-
"""Test that PostgreSQL connection from env vars works with real database.
|
|
186
|
-
|
|
187
|
-
Requires POSTGRES_TEST_URL environment variable to be set.
|
|
188
|
-
Format: postgresql://user:pass@host:port/database
|
|
189
|
-
"""
|
|
190
|
-
if not ASYNC_PG_AVAILABLE:
|
|
191
|
-
pytest.skip("asyncpg not installed")
|
|
192
|
-
|
|
193
|
-
# Check if real PostgreSQL is available
|
|
194
|
-
test_url = os.getenv("POSTGRES_TEST_URL")
|
|
195
|
-
if not test_url:
|
|
196
|
-
pytest.skip("POSTGRES_TEST_URL not set - skipping live connection test")
|
|
197
|
-
|
|
198
|
-
# Parse the test URL to set up environment variables
|
|
199
|
-
parsed = urlparse(test_url)
|
|
200
|
-
|
|
201
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
|
|
202
|
-
monkeypatch.setenv("BELGIE_POSTGRES_HOST", parsed.hostname or "localhost")
|
|
203
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PORT", str(parsed.port or 5432))
|
|
204
|
-
monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", parsed.path.lstrip("/") if parsed.path else "postgres")
|
|
205
|
-
monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", parsed.username or "postgres")
|
|
206
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", parsed.password or "")
|
|
207
|
-
|
|
208
|
-
try:
|
|
209
|
-
db = DatabaseSettings.from_env()
|
|
210
|
-
|
|
211
|
-
# Test basic connection
|
|
212
|
-
async with db.engine.connect() as conn:
|
|
213
|
-
result = await conn.execute(text("SELECT 1 as test"))
|
|
214
|
-
value = result.scalar_one()
|
|
215
|
-
assert value == 1
|
|
216
|
-
|
|
217
|
-
# Test session creation
|
|
218
|
-
async with db.session_maker() as session:
|
|
219
|
-
assert isinstance(session, AsyncSession)
|
|
220
|
-
result = await session.execute(text("SELECT version()"))
|
|
221
|
-
version = result.scalar_one()
|
|
222
|
-
assert "PostgreSQL" in version
|
|
223
|
-
|
|
224
|
-
await db.engine.dispose()
|
|
225
|
-
|
|
226
|
-
except OSError as e:
|
|
227
|
-
pytest.skip(f"Could not connect to PostgreSQL: {e}")
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
# ==================== Validation & Edge Cases ====================
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
@pytest.mark.integration
|
|
234
|
-
def test_missing_required_postgres_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
235
|
-
"""Test that missing required PostgreSQL fields raise validation error."""
|
|
236
|
-
if not ASYNC_PG_AVAILABLE:
|
|
237
|
-
pytest.skip("asyncpg not installed")
|
|
238
|
-
|
|
239
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
|
|
240
|
-
monkeypatch.setenv("BELGIE_POSTGRES_HOST", "localhost")
|
|
241
|
-
# Missing database, username, password
|
|
242
|
-
|
|
243
|
-
with pytest.raises(ValidationError):
|
|
244
|
-
DatabaseSettings.from_env()
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
@pytest.mark.integration
|
|
248
|
-
def test_invalid_port_number(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
249
|
-
"""Test that invalid port number raises validation error."""
|
|
250
|
-
if not ASYNC_PG_AVAILABLE:
|
|
251
|
-
pytest.skip("asyncpg not installed")
|
|
252
|
-
|
|
253
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
|
|
254
|
-
monkeypatch.setenv("BELGIE_POSTGRES_HOST", "localhost")
|
|
255
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PORT", "-1") # Invalid
|
|
256
|
-
monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "testdb")
|
|
257
|
-
monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "user")
|
|
258
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "pass")
|
|
259
|
-
|
|
260
|
-
with pytest.raises(ValidationError, match="Input should be greater than 0"):
|
|
261
|
-
DatabaseSettings.from_env()
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
@pytest.mark.integration
|
|
265
|
-
def test_case_insensitive_boolean_values(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
266
|
-
"""Test that boolean environment variables accept various formats."""
|
|
267
|
-
test_cases = [
|
|
268
|
-
("true", True),
|
|
269
|
-
("True", True),
|
|
270
|
-
("TRUE", True),
|
|
271
|
-
("1", True),
|
|
272
|
-
("false", False),
|
|
273
|
-
("False", False),
|
|
274
|
-
("FALSE", False),
|
|
275
|
-
("0", False),
|
|
276
|
-
]
|
|
277
|
-
|
|
278
|
-
for env_value, expected in test_cases:
|
|
279
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
|
|
280
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
|
|
281
|
-
monkeypatch.setenv("BELGIE_SQLITE_ECHO", env_value)
|
|
282
|
-
|
|
283
|
-
db = DatabaseSettings.from_env()
|
|
284
|
-
assert db.dialect.echo is expected, f"Expected {expected} for env value '{env_value}'"
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
@pytest.mark.asyncio
|
|
288
|
-
@pytest.mark.integration
|
|
289
|
-
async def test_session_maker_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
290
|
-
"""Test that session_maker created from env vars works correctly."""
|
|
291
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
|
|
292
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
|
|
293
|
-
|
|
294
|
-
db = DatabaseSettings.from_env()
|
|
295
|
-
|
|
296
|
-
# Verify session maker settings
|
|
297
|
-
assert db.session_maker.kw["expire_on_commit"] is False
|
|
298
|
-
|
|
299
|
-
# Test session creation
|
|
300
|
-
async with db.session_maker() as session:
|
|
301
|
-
assert isinstance(session, AsyncSession)
|
|
302
|
-
result = await session.execute(text("SELECT 1"))
|
|
303
|
-
assert result.scalar_one() == 1
|
|
304
|
-
|
|
305
|
-
await db.engine.dispose()
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
@pytest.mark.asyncio
|
|
309
|
-
@pytest.mark.integration
|
|
310
|
-
async def test_dependency_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
311
|
-
"""Test that dependency generator from env vars works correctly."""
|
|
312
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
|
|
313
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
|
|
314
|
-
|
|
315
|
-
db = DatabaseSettings.from_env()
|
|
316
|
-
|
|
317
|
-
# Test dependency yields sessions
|
|
318
|
-
sessions = []
|
|
319
|
-
async for session in db.dependency():
|
|
320
|
-
sessions.append(session)
|
|
321
|
-
result = await session.execute(text("SELECT 1"))
|
|
322
|
-
assert result.scalar_one() == 1
|
|
323
|
-
break
|
|
324
|
-
|
|
325
|
-
async for session in db.dependency():
|
|
326
|
-
sessions.append(session)
|
|
327
|
-
break
|
|
328
|
-
|
|
329
|
-
# Verify different sessions
|
|
330
|
-
assert len(sessions) == 2
|
|
331
|
-
assert sessions[0] is not sessions[1]
|
|
332
|
-
|
|
333
|
-
await db.engine.dispose()
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
# ==================== Mixed Configuration Tests ====================
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
@pytest.mark.integration
|
|
340
|
-
def test_can_override_with_direct_instantiation(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
341
|
-
"""Test that direct instantiation still works regardless of env vars."""
|
|
342
|
-
# Set env vars that would load postgres
|
|
343
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
|
|
344
|
-
monkeypatch.setenv("BELGIE_POSTGRES_HOST", "localhost")
|
|
345
|
-
monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "testdb")
|
|
346
|
-
monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "user")
|
|
347
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "pass")
|
|
348
|
-
|
|
349
|
-
# But directly instantiate SQLite
|
|
350
|
-
db = DatabaseSettings(dialect={"type": "sqlite", "database": ":memory:"})
|
|
351
|
-
|
|
352
|
-
assert db.dialect.type == "sqlite"
|
|
353
|
-
assert db.dialect.database == ":memory:"
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
@pytest.mark.integration
|
|
357
|
-
def test_from_env_vs_direct_instantiation(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
358
|
-
"""Test the difference between from_env() and direct instantiation."""
|
|
359
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
|
|
360
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", "/tmp/from_env.db") # noqa: S108
|
|
361
|
-
monkeypatch.setenv("BELGIE_SQLITE_ECHO", "true")
|
|
362
|
-
|
|
363
|
-
# from_env() reads environment variables
|
|
364
|
-
db_from_env = DatabaseSettings.from_env()
|
|
365
|
-
assert db_from_env.dialect.database == "/tmp/from_env.db" # noqa: S108
|
|
366
|
-
assert db_from_env.dialect.echo is True
|
|
367
|
-
|
|
368
|
-
# Direct instantiation with explicit values overrides env vars
|
|
369
|
-
db_direct = DatabaseSettings(dialect={"type": "sqlite", "database": ":memory:", "echo": False})
|
|
370
|
-
assert db_direct.dialect.database == ":memory:"
|
|
371
|
-
assert db_direct.dialect.echo is False
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
@pytest.mark.integration
|
|
375
|
-
def test_no_database_type_env_var_defaults_to_sqlite(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
376
|
-
"""Test that missing BELGIE_DATABASE_TYPE defaults to SQLite."""
|
|
377
|
-
# Don't set BELGIE_DATABASE_TYPE
|
|
378
|
-
monkeypatch.setenv("BELGIE_SQLITE_DATABASE", ":memory:")
|
|
379
|
-
|
|
380
|
-
db = DatabaseSettings.from_env()
|
|
381
|
-
|
|
382
|
-
assert db.dialect.type == "sqlite"
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
@pytest.mark.integration
|
|
386
|
-
def test_postgres_connection_string_format(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
387
|
-
"""Test that Postgres settings correctly build connection URL."""
|
|
388
|
-
if not ASYNC_PG_AVAILABLE:
|
|
389
|
-
pytest.skip("asyncpg not installed")
|
|
390
|
-
|
|
391
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "postgres")
|
|
392
|
-
monkeypatch.setenv("BELGIE_POSTGRES_HOST", "testhost.example.com")
|
|
393
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PORT", "5433")
|
|
394
|
-
monkeypatch.setenv("BELGIE_POSTGRES_DATABASE", "testdb")
|
|
395
|
-
monkeypatch.setenv("BELGIE_POSTGRES_USERNAME", "testuser")
|
|
396
|
-
monkeypatch.setenv("BELGIE_POSTGRES_PASSWORD", "testpass123")
|
|
397
|
-
|
|
398
|
-
db = DatabaseSettings.from_env()
|
|
399
|
-
|
|
400
|
-
# Verify URL components
|
|
401
|
-
assert db.engine.url.get_backend_name() == "postgresql"
|
|
402
|
-
assert db.engine.url.get_driver_name() == "asyncpg"
|
|
403
|
-
assert db.engine.url.host == "testhost.example.com"
|
|
404
|
-
assert db.engine.url.port == 5433
|
|
405
|
-
assert db.engine.url.database == "testdb"
|
|
406
|
-
assert db.engine.url.username == "testuser"
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
@pytest.mark.integration
|
|
410
|
-
def test_sqlite_missing_database_field(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
411
|
-
"""Test that SQLite requires database field."""
|
|
412
|
-
monkeypatch.setenv("BELGIE_DATABASE_TYPE", "sqlite")
|
|
413
|
-
# Missing BELGIE_SQLITE_DATABASE
|
|
414
|
-
|
|
415
|
-
with pytest.raises(ValidationError):
|
|
416
|
-
DatabaseSettings.from_env()
|
|
File without changes
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
from datetime import UTC, datetime, timedelta, timezone
|
|
2
|
-
from zoneinfo import ZoneInfo
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
from sqlalchemy import Integer
|
|
6
|
-
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
7
|
-
from sqlalchemy.orm import Mapped, mapped_column
|
|
8
|
-
|
|
9
|
-
from belgie_alchemy.base import Base
|
|
10
|
-
from belgie_alchemy.types import DateTimeUTC
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class Event(Base):
|
|
14
|
-
__tablename__ = "events"
|
|
15
|
-
|
|
16
|
-
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, init=False)
|
|
17
|
-
happened_at: Mapped[datetime] = mapped_column(DateTimeUTC, nullable=False)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@pytest.mark.asyncio
|
|
21
|
-
async def test_datetimeutc_roundtrip(
|
|
22
|
-
alchemy_engine: AsyncEngine,
|
|
23
|
-
alchemy_session: AsyncSession,
|
|
24
|
-
) -> None:
|
|
25
|
-
async with alchemy_engine.begin() as conn:
|
|
26
|
-
await conn.run_sync(Event.__table__.create, checkfirst=True)
|
|
27
|
-
|
|
28
|
-
naive = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
|
|
29
|
-
aware = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
|
|
30
|
-
|
|
31
|
-
event1 = Event(happened_at=naive)
|
|
32
|
-
event2 = Event(happened_at=aware)
|
|
33
|
-
|
|
34
|
-
alchemy_session.add_all([event1, event2])
|
|
35
|
-
await alchemy_session.commit()
|
|
36
|
-
|
|
37
|
-
rows = (await alchemy_session.execute(Event.__table__.select())).all()
|
|
38
|
-
values = [row.happened_at for row in rows]
|
|
39
|
-
assert all(val.tzinfo is UTC for val in values)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_datetimeutc_rejects_non_datetime() -> None:
|
|
43
|
-
"""Test that DateTimeUTC rejects non-datetime values with helpful error."""
|
|
44
|
-
dt = DateTimeUTC()
|
|
45
|
-
with pytest.raises(TypeError, match=r"DateTimeUTC requires datetime object, got str"):
|
|
46
|
-
dt.process_bind_param("not-a-datetime", None) # type: ignore[arg-type]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_datetimeutc_error_message_includes_guidance() -> None:
|
|
50
|
-
"""Test that error message provides actionable guidance."""
|
|
51
|
-
dt = DateTimeUTC()
|
|
52
|
-
with pytest.raises(TypeError, match=r"datetime\.combine"):
|
|
53
|
-
dt.process_bind_param("2024-01-01", None) # type: ignore[arg-type]
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_datetimeutc_handles_none() -> None:
|
|
57
|
-
"""Test that DateTimeUTC properly handles None values."""
|
|
58
|
-
dt = DateTimeUTC()
|
|
59
|
-
result = dt.process_bind_param(None, None)
|
|
60
|
-
assert result is None
|
|
61
|
-
|
|
62
|
-
result = dt.process_result_value(None, None)
|
|
63
|
-
assert result is None
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def test_datetimeutc_converts_naive_to_utc() -> None:
|
|
67
|
-
"""Test that naive datetimes are converted to UTC-aware."""
|
|
68
|
-
dt = DateTimeUTC()
|
|
69
|
-
naive = datetime(2024, 1, 1, 12, 0, 0) # noqa: DTZ001 - intentionally testing naive datetime
|
|
70
|
-
|
|
71
|
-
result = dt.process_bind_param(naive, None)
|
|
72
|
-
|
|
73
|
-
assert result is not None
|
|
74
|
-
assert result.tzinfo is UTC
|
|
75
|
-
assert result.year == 2024
|
|
76
|
-
assert result.month == 1
|
|
77
|
-
assert result.day == 1
|
|
78
|
-
assert result.hour == 12
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_datetimeutc_converts_other_timezones_to_utc() -> None:
|
|
82
|
-
"""Test that datetimes in other timezones are converted to UTC."""
|
|
83
|
-
dt = DateTimeUTC()
|
|
84
|
-
|
|
85
|
-
# Create datetime in US/Eastern (UTC-5 in winter)
|
|
86
|
-
eastern = ZoneInfo("America/New_York")
|
|
87
|
-
eastern_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=eastern)
|
|
88
|
-
|
|
89
|
-
result = dt.process_bind_param(eastern_time, None)
|
|
90
|
-
|
|
91
|
-
assert result is not None
|
|
92
|
-
assert result.tzinfo is UTC
|
|
93
|
-
# 12:00 EST should be 17:00 UTC (approximately, depending on DST)
|
|
94
|
-
assert result.hour in (16, 17) # Allow for DST variations
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def test_datetimeutc_preserves_utc_datetime() -> None:
|
|
98
|
-
"""Test that UTC datetimes are preserved as-is."""
|
|
99
|
-
dt = DateTimeUTC()
|
|
100
|
-
utc_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
|
|
101
|
-
|
|
102
|
-
result = dt.process_bind_param(utc_time, None)
|
|
103
|
-
|
|
104
|
-
assert result is not None
|
|
105
|
-
assert result == utc_time
|
|
106
|
-
assert result.tzinfo is UTC
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
@pytest.mark.asyncio
|
|
110
|
-
async def test_datetimeutc_naive_datetime_roundtrip(
|
|
111
|
-
alchemy_engine: AsyncEngine,
|
|
112
|
-
alchemy_session: AsyncSession,
|
|
113
|
-
) -> None:
|
|
114
|
-
"""Test that naive datetimes are stored and retrieved as UTC-aware."""
|
|
115
|
-
async with alchemy_engine.begin() as conn:
|
|
116
|
-
await conn.run_sync(Event.__table__.create, checkfirst=True)
|
|
117
|
-
|
|
118
|
-
# Create event with naive datetime
|
|
119
|
-
naive_time = datetime(2024, 6, 15, 14, 30, 0) # noqa: DTZ001 - intentionally testing naive datetime
|
|
120
|
-
event = Event(happened_at=naive_time)
|
|
121
|
-
|
|
122
|
-
alchemy_session.add(event)
|
|
123
|
-
await alchemy_session.commit()
|
|
124
|
-
|
|
125
|
-
# Retrieve and verify it's UTC-aware
|
|
126
|
-
await alchemy_session.refresh(event)
|
|
127
|
-
assert event.happened_at.tzinfo is UTC
|
|
128
|
-
assert event.happened_at.year == 2024
|
|
129
|
-
assert event.happened_at.month == 6
|
|
130
|
-
assert event.happened_at.day == 15
|
|
131
|
-
assert event.happened_at.hour == 14
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
@pytest.mark.asyncio
|
|
135
|
-
async def test_datetimeutc_timezone_conversion_roundtrip(
|
|
136
|
-
alchemy_engine: AsyncEngine,
|
|
137
|
-
alchemy_session: AsyncSession,
|
|
138
|
-
) -> None:
|
|
139
|
-
"""Test that datetimes in other timezones are converted to UTC on storage."""
|
|
140
|
-
async with alchemy_engine.begin() as conn:
|
|
141
|
-
await conn.run_sync(Event.__table__.create, checkfirst=True)
|
|
142
|
-
|
|
143
|
-
# Create event with Tokyo time (UTC+9)
|
|
144
|
-
tokyo_tz = timezone(timedelta(hours=9))
|
|
145
|
-
tokyo_time = datetime(2024, 1, 1, 21, 0, 0, tzinfo=tokyo_tz) # 21:00 Tokyo
|
|
146
|
-
|
|
147
|
-
event = Event(happened_at=tokyo_time)
|
|
148
|
-
alchemy_session.add(event)
|
|
149
|
-
await alchemy_session.commit()
|
|
150
|
-
|
|
151
|
-
# Retrieve and verify it's converted to UTC
|
|
152
|
-
await alchemy_session.refresh(event)
|
|
153
|
-
assert event.happened_at.tzinfo is UTC
|
|
154
|
-
assert event.happened_at.hour == 12 # 21:00 - 9 hours = 12:00 UTC
|
|
155
|
-
assert event.happened_at.day == 1
|
belgie_alchemy/base.py
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
from typing import Any, ClassVar, Final
|
|
3
|
-
|
|
4
|
-
from sqlalchemy import MetaData
|
|
5
|
-
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
|
|
6
|
-
|
|
7
|
-
from belgie_alchemy.types import DateTimeUTC
|
|
8
|
-
|
|
9
|
-
NAMING_CONVENTION: Final[dict[str, str]] = {
|
|
10
|
-
"ix": "ix_%(column_0_label)s",
|
|
11
|
-
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
12
|
-
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
13
|
-
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
14
|
-
"pk": "pk_%(table_name)s",
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
TYPE_ANNOTATION_MAP: Final[dict[type | Any, object]] = {
|
|
18
|
-
datetime: DateTimeUTC,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class Base(MappedAsDataclass, DeclarativeBase):
|
|
23
|
-
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
|
24
|
-
type_annotation_map = TYPE_ANNOTATION_MAP
|
|
25
|
-
__sa_dataclass_kwargs__: ClassVar[dict[str, bool]] = {"kw_only": True, "repr": True, "eq": True}
|
belgie_alchemy/mixins.py
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
from uuid import UUID, uuid4
|
|
3
|
-
|
|
4
|
-
from sqlalchemy import func
|
|
5
|
-
from sqlalchemy.orm import Mapped, MappedAsDataclass, declarative_mixin, mapped_column
|
|
6
|
-
|
|
7
|
-
from belgie_alchemy.types import DateTimeUTC
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@declarative_mixin
|
|
11
|
-
class PrimaryKeyMixin(MappedAsDataclass):
|
|
12
|
-
"""Mixin that adds a UUID primary key column.
|
|
13
|
-
|
|
14
|
-
Inherits from MappedAsDataclass to support standalone usage without Base.
|
|
15
|
-
When used with Base (which also inherits MappedAsDataclass), the duplicate
|
|
16
|
-
inheritance is safely handled by Python's MRO (Method Resolution Order).
|
|
17
|
-
|
|
18
|
-
The id field is excluded from __init__ (init=False) and is automatically
|
|
19
|
-
generated both client-side (default_factory=uuid4) and server-side
|
|
20
|
-
(server_default=gen_random_uuid()) for maximum compatibility.
|
|
21
|
-
|
|
22
|
-
Usage:
|
|
23
|
-
class MyModel(Base, PrimaryKeyMixin, TimestampMixin):
|
|
24
|
-
__tablename__ = "my_table"
|
|
25
|
-
name: Mapped[str]
|
|
26
|
-
|
|
27
|
-
The UUID is:
|
|
28
|
-
- Generated client-side by default (uuid4)
|
|
29
|
-
- Has server-side fallback (gen_random_uuid() for PostgreSQL)
|
|
30
|
-
- Indexed and unique for efficient lookups
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
id: Mapped[UUID] = mapped_column(
|
|
34
|
-
primary_key=True,
|
|
35
|
-
default_factory=uuid4,
|
|
36
|
-
server_default=func.gen_random_uuid(),
|
|
37
|
-
index=True,
|
|
38
|
-
unique=True,
|
|
39
|
-
init=False,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@declarative_mixin
|
|
44
|
-
class TimestampMixin(MappedAsDataclass):
|
|
45
|
-
"""Mixin that adds automatic timestamp tracking columns.
|
|
46
|
-
|
|
47
|
-
Inherits from MappedAsDataclass to support standalone usage without Base.
|
|
48
|
-
When used with Base (which also inherits MappedAsDataclass), the duplicate
|
|
49
|
-
inheritance is safely handled by Python's MRO (Method Resolution Order).
|
|
50
|
-
|
|
51
|
-
All timestamp fields are excluded from __init__ (init=False) and are
|
|
52
|
-
automatically managed by the database using UTC-aware datetimes.
|
|
53
|
-
|
|
54
|
-
Usage:
|
|
55
|
-
class MyModel(Base, PrimaryKeyMixin, TimestampMixin):
|
|
56
|
-
__tablename__ = "my_table"
|
|
57
|
-
name: Mapped[str]
|
|
58
|
-
|
|
59
|
-
Fields:
|
|
60
|
-
created_at: Set automatically on insert (UTC-aware)
|
|
61
|
-
updated_at: Set automatically on insert and update (UTC-aware)
|
|
62
|
-
deleted_at: NULL by default, set via mark_deleted() for soft deletion
|
|
63
|
-
|
|
64
|
-
Soft Deletion:
|
|
65
|
-
Use mark_deleted() to mark an entity as deleted without removing it
|
|
66
|
-
from the database. Remember to commit the session after calling.
|
|
67
|
-
"""
|
|
68
|
-
|
|
69
|
-
created_at: Mapped[datetime] = mapped_column(DateTimeUTC, default=func.now(), init=False)
|
|
70
|
-
updated_at: Mapped[datetime] = mapped_column(DateTimeUTC, default=func.now(), onupdate=func.now(), init=False)
|
|
71
|
-
deleted_at: Mapped[datetime | None] = mapped_column(DateTimeUTC, nullable=True, default=None, init=False)
|
|
72
|
-
|
|
73
|
-
def mark_deleted(self) -> None:
|
|
74
|
-
"""Mark this entity as deleted by setting deleted_at timestamp.
|
|
75
|
-
|
|
76
|
-
Note: This only sets the field, it does not persist to the database.
|
|
77
|
-
You must commit the session to save the change.
|
|
78
|
-
|
|
79
|
-
Example:
|
|
80
|
-
user.mark_deleted()
|
|
81
|
-
await session.commit() # Persist the soft delete
|
|
82
|
-
"""
|
|
83
|
-
self.deleted_at = func.now()
|