core-framework 0.12.6__py3-none-any.whl → 0.12.8__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.
@@ -0,0 +1,307 @@
1
+ """
2
+ Test database utilities.
3
+
4
+ Provides functions for setting up and tearing down test databases,
5
+ with support for both SQLite (in-memory) and PostgreSQL.
6
+
7
+ Usage:
8
+ # Setup
9
+ engine = await setup_test_db("sqlite+aiosqlite:///:memory:")
10
+
11
+ # Get session
12
+ async with get_test_session() as session:
13
+ user = User(email="test@example.com")
14
+ session.add(user)
15
+ await session.commit()
16
+
17
+ # Cleanup
18
+ await teardown_test_db()
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import TYPE_CHECKING, Any
25
+ from contextlib import asynccontextmanager
26
+
27
+ if TYPE_CHECKING:
28
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
29
+ from collections.abc import AsyncGenerator
30
+
31
+ logger = logging.getLogger("core.testing")
32
+
33
+ # Global test database state
34
+ _test_engine: "AsyncEngine | None" = None
35
+ _test_session_factory: Any = None
36
+
37
+
38
+ class TestDatabase:
39
+ """
40
+ Test database manager.
41
+
42
+ Manages test database lifecycle including setup, session management,
43
+ and cleanup. Supports both SQLite and PostgreSQL.
44
+
45
+ Example:
46
+ db = TestDatabase("sqlite+aiosqlite:///:memory:")
47
+ await db.setup()
48
+
49
+ async with db.session() as session:
50
+ # Use session
51
+ pass
52
+
53
+ await db.teardown()
54
+
55
+ Args:
56
+ database_url: Database URL
57
+ echo: Whether to log SQL statements
58
+ create_tables: Whether to create tables on setup
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ database_url: str = "sqlite+aiosqlite:///:memory:",
64
+ echo: bool = False,
65
+ create_tables: bool = True,
66
+ ) -> None:
67
+ self.database_url = database_url
68
+ self.echo = echo
69
+ self.create_tables = create_tables
70
+ self._engine: "AsyncEngine | None" = None
71
+ self._session_factory: Any = None
72
+
73
+ async def setup(self) -> "AsyncEngine":
74
+ """
75
+ Initialize test database.
76
+
77
+ Creates engine, session factory, and optionally creates all tables.
78
+
79
+ Returns:
80
+ The database engine
81
+ """
82
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
83
+
84
+ # Create engine with test-specific settings
85
+ self._engine = create_async_engine(
86
+ self.database_url,
87
+ echo=self.echo,
88
+ # SQLite-specific settings
89
+ connect_args={"check_same_thread": False} if "sqlite" in self.database_url else {},
90
+ )
91
+
92
+ # Create session factory
93
+ self._session_factory = async_sessionmaker(
94
+ self._engine,
95
+ class_=AsyncSession,
96
+ expire_on_commit=False,
97
+ )
98
+
99
+ # Create tables if requested
100
+ if self.create_tables:
101
+ await self._create_tables()
102
+
103
+ logger.debug(f"Test database initialized: {self.database_url}")
104
+ return self._engine
105
+
106
+ async def teardown(self) -> None:
107
+ """
108
+ Cleanup test database.
109
+
110
+ Drops all tables and disposes of the engine.
111
+ """
112
+ if self._engine:
113
+ # Drop tables
114
+ await self._drop_tables()
115
+
116
+ # Dispose engine
117
+ await self._engine.dispose()
118
+ self._engine = None
119
+ self._session_factory = None
120
+
121
+ logger.debug("Test database cleaned up")
122
+
123
+ async def _create_tables(self) -> None:
124
+ """Create all tables from registered models."""
125
+ try:
126
+ from core.models import Model
127
+ metadata = Model.metadata
128
+ except ImportError:
129
+ logger.warning("Could not import Model, tables not created")
130
+ return
131
+
132
+ async with self._engine.begin() as conn:
133
+ await conn.run_sync(metadata.create_all)
134
+
135
+ logger.debug("Test database tables created")
136
+
137
+ async def _drop_tables(self) -> None:
138
+ """Drop all tables."""
139
+ try:
140
+ from core.models import Model
141
+ metadata = Model.metadata
142
+ except ImportError:
143
+ return
144
+
145
+ async with self._engine.begin() as conn:
146
+ await conn.run_sync(metadata.drop_all)
147
+
148
+ @asynccontextmanager
149
+ async def session(self) -> "AsyncGenerator[AsyncSession, None]":
150
+ """
151
+ Get a database session.
152
+
153
+ Session is automatically committed on success and rolled back on error.
154
+
155
+ Yields:
156
+ Database session
157
+ """
158
+ if not self._session_factory:
159
+ raise RuntimeError("Database not initialized. Call setup() first.")
160
+
161
+ session = self._session_factory()
162
+ try:
163
+ yield session
164
+ await session.commit()
165
+ except Exception:
166
+ await session.rollback()
167
+ raise
168
+ finally:
169
+ await session.close()
170
+
171
+ async def truncate_all(self) -> None:
172
+ """
173
+ Truncate all tables (faster than drop/create for resetting between tests).
174
+ """
175
+ try:
176
+ from core.models import Model
177
+ metadata = Model.metadata
178
+ except ImportError:
179
+ return
180
+
181
+ async with self._engine.begin() as conn:
182
+ for table in reversed(metadata.sorted_tables):
183
+ await conn.execute(table.delete())
184
+
185
+ logger.debug("Test database tables truncated")
186
+
187
+ async def __aenter__(self) -> "TestDatabase":
188
+ """Setup on context enter."""
189
+ await self.setup()
190
+ return self
191
+
192
+ async def __aexit__(self, *args) -> None:
193
+ """Teardown on context exit."""
194
+ await self.teardown()
195
+
196
+
197
+ # =============================================================================
198
+ # Module-level functions for simpler usage
199
+ # =============================================================================
200
+
201
+ async def setup_test_db(
202
+ database_url: str = "sqlite+aiosqlite:///:memory:",
203
+ create_tables: bool = True,
204
+ echo: bool = False,
205
+ ) -> "AsyncEngine":
206
+ """
207
+ Setup test database.
208
+
209
+ Initializes the global test database state.
210
+
211
+ Args:
212
+ database_url: Database URL
213
+ create_tables: Whether to create tables
214
+ echo: Whether to log SQL
215
+
216
+ Returns:
217
+ Database engine
218
+
219
+ Example:
220
+ engine = await setup_test_db()
221
+ # ... run tests ...
222
+ await teardown_test_db()
223
+ """
224
+ global _test_engine, _test_session_factory
225
+
226
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
227
+
228
+ _test_engine = create_async_engine(
229
+ database_url,
230
+ echo=echo,
231
+ connect_args={"check_same_thread": False} if "sqlite" in database_url else {},
232
+ )
233
+
234
+ _test_session_factory = async_sessionmaker(
235
+ _test_engine,
236
+ class_=AsyncSession,
237
+ expire_on_commit=False,
238
+ )
239
+
240
+ if create_tables:
241
+ try:
242
+ from core.models import Model
243
+ async with _test_engine.begin() as conn:
244
+ await conn.run_sync(Model.metadata.create_all)
245
+ except ImportError:
246
+ pass
247
+
248
+ # Also register with core.database for dependencies
249
+ try:
250
+ from core import database
251
+ database._write_session_factory = _test_session_factory
252
+ database._read_session_factory = _test_session_factory
253
+ except (ImportError, AttributeError):
254
+ pass
255
+
256
+ logger.debug(f"Test database setup: {database_url}")
257
+ return _test_engine
258
+
259
+
260
+ async def teardown_test_db() -> None:
261
+ """
262
+ Teardown test database.
263
+
264
+ Cleans up the global test database state.
265
+ """
266
+ global _test_engine, _test_session_factory
267
+
268
+ if _test_engine:
269
+ # Drop tables
270
+ try:
271
+ from core.models import Model
272
+ async with _test_engine.begin() as conn:
273
+ await conn.run_sync(Model.metadata.drop_all)
274
+ except ImportError:
275
+ pass
276
+
277
+ await _test_engine.dispose()
278
+ _test_engine = None
279
+ _test_session_factory = None
280
+
281
+ logger.debug("Test database teardown complete")
282
+
283
+
284
+ @asynccontextmanager
285
+ async def get_test_session() -> "AsyncGenerator[AsyncSession, None]":
286
+ """
287
+ Get a test database session.
288
+
289
+ Yields:
290
+ Database session
291
+
292
+ Example:
293
+ async with get_test_session() as session:
294
+ user = await User.objects.using(session).get(id=1)
295
+ """
296
+ if not _test_session_factory:
297
+ raise RuntimeError("Test database not initialized. Call setup_test_db() first.")
298
+
299
+ session = _test_session_factory()
300
+ try:
301
+ yield session
302
+ await session.commit()
303
+ except Exception:
304
+ await session.rollback()
305
+ raise
306
+ finally:
307
+ await session.close()
@@ -0,0 +1,393 @@
1
+ """
2
+ Data factories for generating test data.
3
+
4
+ Provides a factory pattern for creating test instances with fake data.
5
+
6
+ Usage:
7
+ # Define a factory
8
+ class UserFactory(Factory):
9
+ model = User
10
+
11
+ @classmethod
12
+ def build(cls, **overrides):
13
+ return {
14
+ "email": fake.email(),
15
+ "name": fake.name(),
16
+ **overrides,
17
+ }
18
+
19
+ # Use in tests
20
+ async def test_user_creation(db):
21
+ user = await UserFactory.create(db)
22
+ assert user.id is not None
23
+
24
+ users = await UserFactory.create_batch(db, 5)
25
+ assert len(users) == 5
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ from typing import TypeVar, Generic, Any, TYPE_CHECKING
32
+ from uuid import uuid4
33
+
34
+ if TYPE_CHECKING:
35
+ from sqlalchemy.ext.asyncio import AsyncSession
36
+
37
+ logger = logging.getLogger("core.testing")
38
+
39
+ # Try to import faker, provide fallback
40
+ try:
41
+ from faker import Faker
42
+ fake = Faker()
43
+ except ImportError:
44
+ # Minimal fake data generator if faker not installed
45
+ class MinimalFaker:
46
+ """Minimal faker replacement when faker is not installed."""
47
+
48
+ _counter = 0
49
+
50
+ def email(self) -> str:
51
+ self._counter += 1
52
+ return f"user{self._counter}@example.com"
53
+
54
+ def name(self) -> str:
55
+ self._counter += 1
56
+ return f"User {self._counter}"
57
+
58
+ def first_name(self) -> str:
59
+ return "John"
60
+
61
+ def last_name(self) -> str:
62
+ return "Doe"
63
+
64
+ def text(self, max_nb_chars: int = 200) -> str:
65
+ return "Lorem ipsum dolor sit amet." * (max_nb_chars // 30 + 1)
66
+
67
+ def sentence(self) -> str:
68
+ return "This is a test sentence."
69
+
70
+ def paragraph(self) -> str:
71
+ return "This is a test paragraph with multiple sentences. It contains some text for testing purposes."
72
+
73
+ def url(self) -> str:
74
+ self._counter += 1
75
+ return f"https://example.com/{self._counter}"
76
+
77
+ def uuid4(self) -> str:
78
+ return str(uuid4())
79
+
80
+ def random_int(self, min: int = 0, max: int = 9999) -> int:
81
+ import random
82
+ return random.randint(min, max)
83
+
84
+ def boolean(self) -> bool:
85
+ import random
86
+ return random.choice([True, False])
87
+
88
+ def date_this_year(self) -> str:
89
+ from datetime import date
90
+ return date.today().isoformat()
91
+
92
+ def date_time_this_year(self):
93
+ from datetime import datetime
94
+ return datetime.now()
95
+
96
+ def company(self) -> str:
97
+ self._counter += 1
98
+ return f"Company {self._counter}"
99
+
100
+ def phone_number(self) -> str:
101
+ self._counter += 1
102
+ return f"+1-555-{self._counter:04d}"
103
+
104
+ def address(self) -> str:
105
+ return "123 Test Street, Test City, TC 12345"
106
+
107
+ fake = MinimalFaker()
108
+ logger.warning(
109
+ "faker not installed. Using minimal fake data generator. "
110
+ "Install with: pip install faker"
111
+ )
112
+
113
+
114
+ T = TypeVar("T")
115
+
116
+
117
+ class Factory(Generic[T]):
118
+ """
119
+ Base factory for creating test instances.
120
+
121
+ Subclass this to create factories for your models.
122
+
123
+ Example:
124
+ class UserFactory(Factory):
125
+ model = User
126
+
127
+ @classmethod
128
+ def build(cls, **overrides):
129
+ return {
130
+ "email": fake.email(),
131
+ "name": fake.name(),
132
+ "is_active": True,
133
+ **overrides,
134
+ }
135
+
136
+ # Build dict (no database)
137
+ data = UserFactory.build(name="Custom Name")
138
+
139
+ # Create instance (saves to database)
140
+ user = await UserFactory.create(db)
141
+
142
+ # Create multiple
143
+ users = await UserFactory.create_batch(db, 10)
144
+
145
+ Attributes:
146
+ model: The model class to create instances of
147
+ """
148
+
149
+ model: type[T]
150
+
151
+ @classmethod
152
+ def build(cls, **overrides) -> dict[str, Any]:
153
+ """
154
+ Build a dict of attributes without saving.
155
+
156
+ Override this method in subclasses to define default values.
157
+
158
+ Args:
159
+ **overrides: Values to override defaults
160
+
161
+ Returns:
162
+ Dict of model attributes
163
+ """
164
+ raise NotImplementedError(
165
+ f"{cls.__name__} must implement build() method"
166
+ )
167
+
168
+ @classmethod
169
+ async def create(
170
+ cls,
171
+ db: "AsyncSession",
172
+ **overrides,
173
+ ) -> T:
174
+ """
175
+ Create and save an instance to the database.
176
+
177
+ Args:
178
+ db: Database session
179
+ **overrides: Values to override defaults
180
+
181
+ Returns:
182
+ Created model instance
183
+ """
184
+ data = cls.build(**overrides)
185
+ instance = cls.model(**data)
186
+
187
+ db.add(instance)
188
+ await db.commit()
189
+ await db.refresh(instance)
190
+
191
+ logger.debug(f"Factory created: {cls.model.__name__}")
192
+ return instance
193
+
194
+ @classmethod
195
+ async def create_batch(
196
+ cls,
197
+ db: "AsyncSession",
198
+ count: int,
199
+ **overrides,
200
+ ) -> list[T]:
201
+ """
202
+ Create multiple instances.
203
+
204
+ Args:
205
+ db: Database session
206
+ count: Number of instances to create
207
+ **overrides: Values to override defaults for all instances
208
+
209
+ Returns:
210
+ List of created instances
211
+ """
212
+ instances = []
213
+ for _ in range(count):
214
+ instance = await cls.create(db, **overrides)
215
+ instances.append(instance)
216
+
217
+ logger.debug(f"Factory created batch: {count} x {cls.model.__name__}")
218
+ return instances
219
+
220
+ @classmethod
221
+ def build_batch(cls, count: int, **overrides) -> list[dict[str, Any]]:
222
+ """
223
+ Build multiple dicts without saving.
224
+
225
+ Args:
226
+ count: Number of dicts to build
227
+ **overrides: Values to override defaults
228
+
229
+ Returns:
230
+ List of dicts
231
+ """
232
+ return [cls.build(**overrides) for _ in range(count)]
233
+
234
+
235
+ class UserFactory(Factory):
236
+ """
237
+ Factory for creating test users.
238
+
239
+ Works with AbstractUser-based models.
240
+
241
+ Example:
242
+ # Create user with random data
243
+ user = await UserFactory.create(db)
244
+
245
+ # Create with specific email
246
+ admin = await UserFactory.create(db, email="admin@example.com", is_superuser=True)
247
+
248
+ # Create batch
249
+ users = await UserFactory.create_batch(db, 10)
250
+ """
251
+
252
+ model: type = None # Set dynamically
253
+ _default_password: str = "TestPass123!"
254
+
255
+ @classmethod
256
+ def _get_model(cls):
257
+ """Get user model from auth config."""
258
+ if cls.model is not None:
259
+ return cls.model
260
+
261
+ try:
262
+ from core.auth.models import get_user_model
263
+ return get_user_model()
264
+ except Exception:
265
+ pass
266
+
267
+ try:
268
+ from core.auth.models import AbstractUser
269
+ return AbstractUser
270
+ except Exception:
271
+ raise RuntimeError(
272
+ "Could not determine User model. "
273
+ "Set UserFactory.model = YourUserModel"
274
+ )
275
+
276
+ @classmethod
277
+ def build(cls, **overrides) -> dict[str, Any]:
278
+ """Build user data dict."""
279
+ password = overrides.pop("password", cls._default_password)
280
+
281
+ data = {
282
+ "email": fake.email(),
283
+ "is_active": True,
284
+ "is_superuser": False,
285
+ "is_staff": False,
286
+ }
287
+ data.update(overrides)
288
+
289
+ # Handle password hashing
290
+ if "password_hash" not in data:
291
+ try:
292
+ from core.auth.hashers import get_hasher
293
+ hasher = get_hasher()
294
+ data["password_hash"] = hasher.hash(password)
295
+ except Exception:
296
+ # Fallback: assume model handles password
297
+ data["_password"] = password
298
+
299
+ return data
300
+
301
+ @classmethod
302
+ async def create(
303
+ cls,
304
+ db: "AsyncSession",
305
+ **overrides,
306
+ ) -> Any:
307
+ """Create and save a user."""
308
+ model = cls._get_model()
309
+
310
+ data = cls.build(**overrides)
311
+
312
+ # Handle password separately if model has set_password
313
+ password = data.pop("_password", None)
314
+
315
+ instance = model(**data)
316
+
317
+ if password and hasattr(instance, "set_password"):
318
+ instance.set_password(password)
319
+
320
+ db.add(instance)
321
+ await db.commit()
322
+ await db.refresh(instance)
323
+
324
+ return instance
325
+
326
+
327
+ # Additional common factories
328
+
329
+ class SequenceFactory:
330
+ """
331
+ Generate sequential values for unique fields.
332
+
333
+ Example:
334
+ seq = SequenceFactory("user_{n}@example.com")
335
+ seq.next() # "user_1@example.com"
336
+ seq.next() # "user_2@example.com"
337
+ """
338
+
339
+ def __init__(self, template: str, start: int = 1) -> None:
340
+ """
341
+ Initialize sequence.
342
+
343
+ Args:
344
+ template: String template with {n} placeholder
345
+ start: Starting number
346
+ """
347
+ self.template = template
348
+ self.counter = start
349
+
350
+ def next(self) -> str:
351
+ """Get next value in sequence."""
352
+ value = self.template.format(n=self.counter)
353
+ self.counter += 1
354
+ return value
355
+
356
+ def reset(self, start: int = 1) -> None:
357
+ """Reset counter."""
358
+ self.counter = start
359
+
360
+
361
+ class LazyAttribute:
362
+ """
363
+ Lazily compute attribute value.
364
+
365
+ Example:
366
+ class PostFactory(Factory):
367
+ model = Post
368
+
369
+ @classmethod
370
+ def build(cls, **overrides):
371
+ return {
372
+ "title": fake.sentence(),
373
+ "slug": LazyAttribute(lambda obj: slugify(obj["title"])),
374
+ **overrides,
375
+ }
376
+ """
377
+
378
+ def __init__(self, func) -> None:
379
+ self.func = func
380
+
381
+ def __call__(self, obj: dict) -> Any:
382
+ return self.func(obj)
383
+
384
+
385
+ def resolve_lazy_attributes(data: dict) -> dict:
386
+ """Resolve any LazyAttribute values in a dict."""
387
+ result = {}
388
+ for key, value in data.items():
389
+ if isinstance(value, LazyAttribute):
390
+ result[key] = value(data)
391
+ else:
392
+ result[key] = value
393
+ return result