core-framework 0.12.7__py3-none-any.whl → 0.12.9__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.
- core/__init__.py +1 -1
- core/cli/__init__.py +2 -0
- core/cli/main.py +163 -0
- core/testing/__init__.py +99 -0
- core/testing/assertions.py +347 -0
- core/testing/client.py +247 -0
- core/testing/database.py +307 -0
- core/testing/factories.py +393 -0
- core/testing/mocks.py +658 -0
- core/testing/plugin.py +635 -0
- {core_framework-0.12.7.dist-info → core_framework-0.12.9.dist-info}/METADATA +6 -1
- {core_framework-0.12.7.dist-info → core_framework-0.12.9.dist-info}/RECORD +14 -7
- {core_framework-0.12.7.dist-info → core_framework-0.12.9.dist-info}/entry_points.txt +3 -0
- {core_framework-0.12.7.dist-info → core_framework-0.12.9.dist-info}/WHEEL +0 -0
|
@@ -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
|