pyrmute 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.
- pyrmute/__init__.py +28 -1
- pyrmute/_migration_manager.py +105 -30
- pyrmute/_registry.py +24 -11
- pyrmute/_schema_manager.py +14 -27
- pyrmute/_version.py +2 -2
- pyrmute/exceptions.py +55 -0
- pyrmute/migration_testing.py +161 -0
- pyrmute/model_diff.py +272 -0
- pyrmute/model_manager.py +501 -19
- pyrmute/model_version.py +16 -8
- pyrmute/types.py +7 -2
- pyrmute-0.2.0.dist-info/METADATA +347 -0
- pyrmute-0.2.0.dist-info/RECORD +17 -0
- pyrmute-0.1.0.dist-info/METADATA +0 -130
- pyrmute-0.1.0.dist-info/RECORD +0 -14
- {pyrmute-0.1.0.dist-info → pyrmute-0.2.0.dist-info}/WHEEL +0 -0
- {pyrmute-0.1.0.dist-info → pyrmute-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {pyrmute-0.1.0.dist-info → pyrmute-0.2.0.dist-info}/top_level.txt +0 -0
pyrmute/model_manager.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
"""Model manager."""
|
2
2
|
|
3
|
-
from collections.abc import Callable
|
3
|
+
from collections.abc import Callable, Iterable
|
4
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
4
5
|
from pathlib import Path
|
5
6
|
from typing import Any, Self
|
6
7
|
|
@@ -9,6 +10,13 @@ from pydantic import BaseModel
|
|
9
10
|
from ._migration_manager import MigrationManager
|
10
11
|
from ._registry import Registry
|
11
12
|
from ._schema_manager import SchemaManager
|
13
|
+
from .exceptions import MigrationError, ModelNotFoundError
|
14
|
+
from .migration_testing import (
|
15
|
+
MigrationTestCase,
|
16
|
+
MigrationTestResult,
|
17
|
+
MigrationTestResults,
|
18
|
+
)
|
19
|
+
from .model_diff import ModelDiff
|
12
20
|
from .model_version import ModelVersion
|
13
21
|
from .types import (
|
14
22
|
DecoratedBaseModel,
|
@@ -23,17 +31,19 @@ from .types import (
|
|
23
31
|
class ModelManager:
|
24
32
|
"""High-level interface for versioned model management.
|
25
33
|
|
26
|
-
|
27
|
-
|
34
|
+
ModelManager provides a unified API for managing schema evolution across different
|
35
|
+
versions of Pydantic models. It handles model registration, automatic migration
|
36
|
+
between versions, schema generation, and batch processing operations.
|
28
37
|
|
29
38
|
Attributes:
|
30
|
-
registry: Registry instance.
|
31
|
-
migration_manager: MigrationManager instance.
|
32
|
-
schema_manager: SchemaManager instance.
|
39
|
+
registry: Registry instance managing all registered model versions.
|
40
|
+
migration_manager: MigrationManager instance handling migration logic and paths.
|
41
|
+
schema_manager: SchemaManager instance for JSON schema generation and export.
|
33
42
|
|
34
|
-
|
43
|
+
Basic Usage:
|
35
44
|
>>> manager = ModelManager()
|
36
45
|
>>>
|
46
|
+
>>> # Register model versions
|
37
47
|
>>> @manager.model("User", "1.0.0")
|
38
48
|
... class UserV1(BaseModel):
|
39
49
|
... name: str
|
@@ -43,10 +53,41 @@ class ModelManager:
|
|
43
53
|
... name: str
|
44
54
|
... email: str
|
45
55
|
>>>
|
56
|
+
>>> # Define migration between versions
|
46
57
|
>>> @manager.migration("User", "1.0.0", "2.0.0")
|
47
58
|
... def migrate(data: MigrationData) -> MigrationData:
|
48
59
|
... return {**data, "email": "unknown@example.com"}
|
49
|
-
|
60
|
+
>>>
|
61
|
+
>>> # Migrate legacy data
|
62
|
+
>>> old_data = {"name": "Alice"}
|
63
|
+
>>> user = manager.migrate(old_data, "User", "1.0.0", "2.0.0")
|
64
|
+
>>> # Result: UserV2(name="Alice", email="unknown@example.com")
|
65
|
+
|
66
|
+
Advanced Features:
|
67
|
+
>>> # Batch migration with parallel processing
|
68
|
+
>>> users = manager.migrate_batch(
|
69
|
+
... legacy_users, "User", "1.0.0", "2.0.0",
|
70
|
+
... parallel=True, max_workers=4
|
71
|
+
... )
|
72
|
+
>>>
|
73
|
+
>>> # Stream large datasets efficiently
|
74
|
+
>>> for user in manager.migrate_batch_streaming(large_dataset, "User", "1.0.0", "2.0.0"):
|
75
|
+
... save_to_database(user)
|
76
|
+
>>>
|
77
|
+
>>> # Compare versions and export schemas
|
78
|
+
>>> diff = manager.diff("User", "1.0.0", "2.0.0")
|
79
|
+
>>> print(diff.to_markdown())
|
80
|
+
>>> manager.dump_schemas("schemas/", separate_definitions=True)
|
81
|
+
>>>
|
82
|
+
>>> # Test migrations with validation
|
83
|
+
>>> results = manager.test_migration(
|
84
|
+
... "User", "1.0.0", "2.0.0",
|
85
|
+
... test_cases=[
|
86
|
+
... ({"name": "Alice"}, {"name": "Alice", "email": "unknown@example.com"})
|
87
|
+
... ]
|
88
|
+
... )
|
89
|
+
>>> results.assert_all_passed()
|
90
|
+
""" # noqa: E501
|
50
91
|
|
51
92
|
def __init__(self: Self) -> None:
|
52
93
|
"""Initialize the versioned model manager."""
|
@@ -60,6 +101,7 @@ class ModelManager:
|
|
60
101
|
version: str | ModelVersion,
|
61
102
|
schema_generator: JsonSchemaGenerator | None = None,
|
62
103
|
enable_ref: bool = False,
|
104
|
+
backward_compatible: bool = False,
|
63
105
|
) -> Callable[[type[DecoratedBaseModel]], type[DecoratedBaseModel]]:
|
64
106
|
"""Register a versioned model.
|
65
107
|
|
@@ -67,8 +109,11 @@ class ModelManager:
|
|
67
109
|
name: Name of the model.
|
68
110
|
version: Semantic version.
|
69
111
|
schema_generator: Optional custom schema generator.
|
70
|
-
enable_ref: If True, this model can be referenced via $ref in
|
71
|
-
|
112
|
+
enable_ref: If True, this model can be referenced via $ref in separate
|
113
|
+
schema files. If False, it will always be inlined.
|
114
|
+
backward_compatible: If True, this model does not need a migration function
|
115
|
+
to migrate to the next version. If a migration function is defined it
|
116
|
+
will use it.
|
72
117
|
|
73
118
|
Returns:
|
74
119
|
Decorator function for model class.
|
@@ -84,7 +129,9 @@ class ModelManager:
|
|
84
129
|
... class CityV1(BaseModel):
|
85
130
|
... city: City
|
86
131
|
"""
|
87
|
-
return self.registry.register(
|
132
|
+
return self.registry.register(
|
133
|
+
name, version, schema_generator, enable_ref, backward_compatible
|
134
|
+
)
|
88
135
|
|
89
136
|
def migration(
|
90
137
|
self: Self,
|
@@ -120,6 +167,78 @@ class ModelManager:
|
|
120
167
|
return self.registry.get_latest(name)
|
121
168
|
return self.registry.get_model(name, version)
|
122
169
|
|
170
|
+
def has_migration_path(
|
171
|
+
self: Self,
|
172
|
+
name: str,
|
173
|
+
from_version: str | ModelVersion,
|
174
|
+
to_version: str | ModelVersion,
|
175
|
+
) -> bool:
|
176
|
+
"""Check if a migration path exists between two versions.
|
177
|
+
|
178
|
+
Args:
|
179
|
+
name: Name of the model.
|
180
|
+
from_version: Source version.
|
181
|
+
to_version: Target version.
|
182
|
+
|
183
|
+
Returns:
|
184
|
+
True if a migration path exists, False otherwise.
|
185
|
+
|
186
|
+
Example:
|
187
|
+
>>> if manager.has_migration_path("User", "1.0.0", "3.0.0"):
|
188
|
+
... users = manager.migrate_batch(old_users, "User", "1.0.0", "3.0.0")
|
189
|
+
... else:
|
190
|
+
... logger.error("Cannot migrate users to v3.0.0")
|
191
|
+
"""
|
192
|
+
from_ver = (
|
193
|
+
ModelVersion.parse(from_version)
|
194
|
+
if isinstance(from_version, str)
|
195
|
+
else from_version
|
196
|
+
)
|
197
|
+
to_ver = (
|
198
|
+
ModelVersion.parse(to_version)
|
199
|
+
if isinstance(to_version, str)
|
200
|
+
else to_version
|
201
|
+
)
|
202
|
+
try:
|
203
|
+
self.migration_manager.validate_migration_path(name, from_ver, to_ver)
|
204
|
+
return True
|
205
|
+
except (KeyError, ModelNotFoundError, MigrationError):
|
206
|
+
return False
|
207
|
+
|
208
|
+
def validate_data(
|
209
|
+
self: Self,
|
210
|
+
data: MigrationData,
|
211
|
+
name: str,
|
212
|
+
version: str | ModelVersion,
|
213
|
+
) -> bool:
|
214
|
+
"""Check if data is valid for a specific model version.
|
215
|
+
|
216
|
+
Validates whether the provided data conforms to the schema of the specified
|
217
|
+
model version without raising an exception.
|
218
|
+
|
219
|
+
Args:
|
220
|
+
data: Data dictionary to validate.
|
221
|
+
name: Name of the model.
|
222
|
+
version: Semantic version to validate against.
|
223
|
+
|
224
|
+
Returns:
|
225
|
+
True if data is valid for the model version, False otherwise.
|
226
|
+
|
227
|
+
Example:
|
228
|
+
>>> data = {"name": "Alice"}
|
229
|
+
>>> is_valid = manager.validate_data(data, "User", "1.0.0")
|
230
|
+
>>> # Returns: True
|
231
|
+
>>>
|
232
|
+
>>> is_valid = manager.validate_data(data, "User", "2.0.0")
|
233
|
+
>>> # Returns: False, missing required field 'email'
|
234
|
+
"""
|
235
|
+
try:
|
236
|
+
model = self.get(name, version)
|
237
|
+
model.model_validate(data)
|
238
|
+
return True
|
239
|
+
except Exception:
|
240
|
+
return False
|
241
|
+
|
123
242
|
def migrate(
|
124
243
|
self: Self,
|
125
244
|
data: MigrationData,
|
@@ -130,7 +249,7 @@ class ModelManager:
|
|
130
249
|
"""Migrate data between versions.
|
131
250
|
|
132
251
|
Args:
|
133
|
-
data: Data dictionary
|
252
|
+
data: Data dictionary to migrate.
|
134
253
|
name: Name of the model.
|
135
254
|
from_version: Source version.
|
136
255
|
to_version: Target version.
|
@@ -138,12 +257,281 @@ class ModelManager:
|
|
138
257
|
Returns:
|
139
258
|
Migrated BaseModel.
|
140
259
|
"""
|
141
|
-
migrated_data = self.
|
142
|
-
data, name, from_version, to_version
|
143
|
-
)
|
260
|
+
migrated_data = self.migrate_data(data, name, from_version, to_version)
|
144
261
|
target_model = self.get(name, to_version)
|
145
262
|
return target_model.model_validate(migrated_data)
|
146
263
|
|
264
|
+
def migrate_data(
|
265
|
+
self: Self,
|
266
|
+
data: MigrationData,
|
267
|
+
name: str,
|
268
|
+
from_version: str | ModelVersion,
|
269
|
+
to_version: str | ModelVersion,
|
270
|
+
) -> MigrationData:
|
271
|
+
"""Migrate data between versions.
|
272
|
+
|
273
|
+
Args:
|
274
|
+
data: Data dictionary to migrate.
|
275
|
+
name: Name of the model.
|
276
|
+
from_version: Source version.
|
277
|
+
to_version: Target version.
|
278
|
+
|
279
|
+
Returns:
|
280
|
+
Raw migrated dictionary.
|
281
|
+
"""
|
282
|
+
return self.migration_manager.migrate(data, name, from_version, to_version)
|
283
|
+
|
284
|
+
def migrate_batch( # noqa: PLR0913
|
285
|
+
self: Self,
|
286
|
+
data_list: Iterable[MigrationData],
|
287
|
+
name: str,
|
288
|
+
from_version: str | ModelVersion,
|
289
|
+
to_version: str | ModelVersion,
|
290
|
+
parallel: bool = False,
|
291
|
+
max_workers: int | None = None,
|
292
|
+
use_processes: bool = False,
|
293
|
+
) -> list[BaseModel]:
|
294
|
+
"""Migrate multiple data items between versions.
|
295
|
+
|
296
|
+
Args:
|
297
|
+
data_list: Iterable of data dictionaries to migrate.
|
298
|
+
name: Name of the model.
|
299
|
+
from_version: Source version.
|
300
|
+
to_version: Target version.
|
301
|
+
parallel: If True, use parallel processing.
|
302
|
+
max_workers: Maximum number of workers for parallel processing. Defaults to
|
303
|
+
None (uses executor default).
|
304
|
+
use_processes: If True, use ProcessPoolExecutor instead of
|
305
|
+
ThreadPoolExecutor. Useful for CPU-intensive migrations.
|
306
|
+
|
307
|
+
Returns:
|
308
|
+
List of migrated BaseModel instances.
|
309
|
+
|
310
|
+
Example:
|
311
|
+
>>> legacy_users = [
|
312
|
+
... {"name": "Alice"},
|
313
|
+
... {"name": "Bob"},
|
314
|
+
... {"name": "Charlie"}
|
315
|
+
... ]
|
316
|
+
>>> users = manager.migrate_batch(
|
317
|
+
... legacy_users,
|
318
|
+
... "User",
|
319
|
+
... from_version="1.0.0",
|
320
|
+
... to_version="3.0.0",
|
321
|
+
... parallel=True
|
322
|
+
... )
|
323
|
+
"""
|
324
|
+
data_list = list(data_list)
|
325
|
+
|
326
|
+
if not data_list:
|
327
|
+
return []
|
328
|
+
|
329
|
+
if not parallel:
|
330
|
+
return [
|
331
|
+
self.migrate(item, name, from_version, to_version) for item in data_list
|
332
|
+
]
|
333
|
+
|
334
|
+
executor_class = ProcessPoolExecutor if use_processes else ThreadPoolExecutor
|
335
|
+
with executor_class(max_workers=max_workers) as executor:
|
336
|
+
futures = [
|
337
|
+
executor.submit(self.migrate, item, name, from_version, to_version)
|
338
|
+
for item in data_list
|
339
|
+
]
|
340
|
+
return [future.result() for future in futures]
|
341
|
+
|
342
|
+
def migrate_batch_data( # noqa: PLR0913
|
343
|
+
self: Self,
|
344
|
+
data_list: Iterable[MigrationData],
|
345
|
+
name: str,
|
346
|
+
from_version: str | ModelVersion,
|
347
|
+
to_version: str | ModelVersion,
|
348
|
+
parallel: bool = False,
|
349
|
+
max_workers: int | None = None,
|
350
|
+
use_processes: bool = False,
|
351
|
+
) -> list[MigrationData]:
|
352
|
+
"""Migrate multiple data items between versions, returning raw dictionaries.
|
353
|
+
|
354
|
+
Args:
|
355
|
+
data_list: Iterable of data dictionaries to migrate.
|
356
|
+
name: Name of the model.
|
357
|
+
from_version: Source version.
|
358
|
+
to_version: Target version.
|
359
|
+
parallel: If True, use parallel processing.
|
360
|
+
max_workers: Maximum number of workers for parallel processing.
|
361
|
+
use_processes: If True, use ProcessPoolExecutor.
|
362
|
+
|
363
|
+
Returns:
|
364
|
+
List of raw migrated dictionaries.
|
365
|
+
|
366
|
+
Example:
|
367
|
+
>>> legacy_data = [{"name": "Alice"}, {"name": "Bob"}]
|
368
|
+
>>> migrated_data = manager.migrate_batch_data(
|
369
|
+
... legacy_data,
|
370
|
+
... "User",
|
371
|
+
... from_version="1.0.0",
|
372
|
+
... to_version="2.0.0"
|
373
|
+
... )
|
374
|
+
"""
|
375
|
+
data_list = list(data_list)
|
376
|
+
|
377
|
+
if not data_list:
|
378
|
+
return []
|
379
|
+
|
380
|
+
if not parallel:
|
381
|
+
return [
|
382
|
+
self.migrate_data(item, name, from_version, to_version)
|
383
|
+
for item in data_list
|
384
|
+
]
|
385
|
+
|
386
|
+
executor_class = ProcessPoolExecutor if use_processes else ThreadPoolExecutor
|
387
|
+
with executor_class(max_workers=max_workers) as executor:
|
388
|
+
futures = [
|
389
|
+
executor.submit(self.migrate_data, item, name, from_version, to_version)
|
390
|
+
for item in data_list
|
391
|
+
]
|
392
|
+
return [future.result() for future in futures]
|
393
|
+
|
394
|
+
def migrate_batch_streaming(
|
395
|
+
self: Self,
|
396
|
+
data_list: Iterable[MigrationData],
|
397
|
+
name: str,
|
398
|
+
from_version: str | ModelVersion,
|
399
|
+
to_version: str | ModelVersion,
|
400
|
+
chunk_size: int = 100,
|
401
|
+
) -> Iterable[BaseModel]:
|
402
|
+
"""Migrate data in chunks, yielding results as they complete.
|
403
|
+
|
404
|
+
Useful for large datasets where you want to start processing results before all
|
405
|
+
migrations complete.
|
406
|
+
|
407
|
+
Args:
|
408
|
+
data_list: Iterable of data dictionaries to migrate.
|
409
|
+
name: Name of the model.
|
410
|
+
from_version: Source version.
|
411
|
+
to_version: Target version.
|
412
|
+
chunk_size: Number of items to process in each chunk.
|
413
|
+
|
414
|
+
Yields:
|
415
|
+
Migrated BaseModel instances.
|
416
|
+
|
417
|
+
Example:
|
418
|
+
>>> legacy_users = load_large_dataset()
|
419
|
+
>>> for user in manager.migrate_batch_streaming(
|
420
|
+
... legacy_users,
|
421
|
+
... "User",
|
422
|
+
... from_version="1.0.0",
|
423
|
+
... to_version="3.0.0"
|
424
|
+
... ):
|
425
|
+
... # Process each user as it's migrated
|
426
|
+
... save_to_database(user)
|
427
|
+
"""
|
428
|
+
chunk = []
|
429
|
+
|
430
|
+
for item in data_list:
|
431
|
+
chunk.append(item)
|
432
|
+
|
433
|
+
if len(chunk) >= chunk_size:
|
434
|
+
yield from self.migrate_batch(chunk, name, from_version, to_version)
|
435
|
+
chunk = []
|
436
|
+
|
437
|
+
if chunk:
|
438
|
+
yield from self.migrate_batch(chunk, name, from_version, to_version)
|
439
|
+
|
440
|
+
def migrate_batch_data_streaming(
|
441
|
+
self: Self,
|
442
|
+
data_list: Iterable[MigrationData],
|
443
|
+
name: str,
|
444
|
+
from_version: str | ModelVersion,
|
445
|
+
to_version: str | ModelVersion,
|
446
|
+
chunk_size: int = 100,
|
447
|
+
) -> Iterable[MigrationData]:
|
448
|
+
"""Migrate data in chunks, yielding raw dictionaries as they complete.
|
449
|
+
|
450
|
+
Useful for large datasets where you want to start processing results before all
|
451
|
+
migrations complete, without the validation overhead.
|
452
|
+
|
453
|
+
Args:
|
454
|
+
data_list: Iterable of data dictionaries to migrate.
|
455
|
+
name: Name of the model.
|
456
|
+
from_version: Source version.
|
457
|
+
to_version: Target version.
|
458
|
+
chunk_size: Number of items to process in each chunk.
|
459
|
+
|
460
|
+
Yields:
|
461
|
+
Raw migrated dictionaries.
|
462
|
+
|
463
|
+
Example:
|
464
|
+
>>> legacy_data = load_large_dataset()
|
465
|
+
>>> for data in manager.migrate_batch_data_streaming(
|
466
|
+
... legacy_data,
|
467
|
+
... "User",
|
468
|
+
... from_version="1.0.0",
|
469
|
+
... to_version="3.0.0"
|
470
|
+
... ):
|
471
|
+
... # Process raw data as it's migrated
|
472
|
+
... bulk_insert_to_database(data)
|
473
|
+
"""
|
474
|
+
chunk = []
|
475
|
+
|
476
|
+
for item in data_list:
|
477
|
+
chunk.append(item)
|
478
|
+
|
479
|
+
if len(chunk) >= chunk_size:
|
480
|
+
yield from self.migrate_batch_data(
|
481
|
+
chunk, name, from_version, to_version
|
482
|
+
)
|
483
|
+
chunk = []
|
484
|
+
|
485
|
+
if chunk:
|
486
|
+
yield from self.migrate_batch_data(chunk, name, from_version, to_version)
|
487
|
+
|
488
|
+
def diff(
|
489
|
+
self: Self,
|
490
|
+
name: str,
|
491
|
+
from_version: str | ModelVersion,
|
492
|
+
to_version: str | ModelVersion,
|
493
|
+
) -> ModelDiff:
|
494
|
+
"""Get a detailed diff between two model versions.
|
495
|
+
|
496
|
+
Compares field names, types, requirements, and default values to provide a
|
497
|
+
comprehensive view of what changed between versions.
|
498
|
+
|
499
|
+
Args:
|
500
|
+
name: Name of the model.
|
501
|
+
from_version: Source version.
|
502
|
+
to_version: Target version.
|
503
|
+
|
504
|
+
Returns:
|
505
|
+
ModelDiff with detailed change information.
|
506
|
+
|
507
|
+
Example:
|
508
|
+
>>> diff = manager.diff("User", "1.0.0", "2.0.0")
|
509
|
+
>>> print(diff.to_markdown())
|
510
|
+
>>> print(f"Added: {diff.added_fields}")
|
511
|
+
>>> print(f"Removed: {diff.removed_fields}")
|
512
|
+
"""
|
513
|
+
from_ver_str = str(
|
514
|
+
ModelVersion.parse(from_version)
|
515
|
+
if isinstance(from_version, str)
|
516
|
+
else from_version
|
517
|
+
)
|
518
|
+
to_ver_str = str(
|
519
|
+
ModelVersion.parse(to_version)
|
520
|
+
if isinstance(to_version, str)
|
521
|
+
else to_version
|
522
|
+
)
|
523
|
+
|
524
|
+
from_model = self.get(name, from_version)
|
525
|
+
to_model = self.get(name, to_version)
|
526
|
+
|
527
|
+
return ModelDiff.from_models(
|
528
|
+
name=name,
|
529
|
+
from_model=from_model,
|
530
|
+
to_model=to_model,
|
531
|
+
from_version=from_ver_str,
|
532
|
+
to_version=to_ver_str,
|
533
|
+
)
|
534
|
+
|
147
535
|
def get_schema(
|
148
536
|
self: Self,
|
149
537
|
name: str,
|
@@ -193,8 +581,8 @@ class ModelManager:
|
|
193
581
|
Args:
|
194
582
|
output_dir: Directory path for output.
|
195
583
|
indent: JSON indentation level.
|
196
|
-
separate_definitions: If True, create separate schema files for
|
197
|
-
|
584
|
+
separate_definitions: If True, create separate schema files for nested
|
585
|
+
models and use $ref to reference them.
|
198
586
|
ref_template: Template for $ref URLs when separate_definitions=True.
|
199
587
|
Defaults to relative file references if not provided.
|
200
588
|
|
@@ -229,8 +617,8 @@ class ModelManager:
|
|
229
617
|
|
230
618
|
Args:
|
231
619
|
output_dir: Directory path for output.
|
232
|
-
ref_template: Template for $ref URLs. Supports {model} and
|
233
|
-
|
620
|
+
ref_template: Template for $ref URLs. Supports {model} and {version}
|
621
|
+
placeholders. Defaults to relative file refs.
|
234
622
|
indent: JSON indentation level.
|
235
623
|
|
236
624
|
Example:
|
@@ -262,3 +650,97 @@ class ModelManager:
|
|
262
650
|
List of (model_name, version) tuples for nested models.
|
263
651
|
"""
|
264
652
|
return self.schema_manager.get_nested_models(name, version)
|
653
|
+
|
654
|
+
def test_migration(
|
655
|
+
self: Self,
|
656
|
+
name: str,
|
657
|
+
from_version: str | ModelVersion,
|
658
|
+
to_version: str | ModelVersion,
|
659
|
+
test_cases: list[tuple[MigrationData, MigrationData] | MigrationTestCase],
|
660
|
+
) -> MigrationTestResults:
|
661
|
+
"""Test a migration with multiple test cases.
|
662
|
+
|
663
|
+
Executes a migration on multiple test inputs and validates the outputs match
|
664
|
+
expected values. Useful for regression testing and validating migration logic.
|
665
|
+
|
666
|
+
Args:
|
667
|
+
name: Name of the model.
|
668
|
+
from_version: Source version to migrate from.
|
669
|
+
to_version: Target version to migrate to.
|
670
|
+
test_cases: List of test cases, either as (source, target) tuples or
|
671
|
+
MigrationTestCase objects. If target is None, only verifies the
|
672
|
+
migration completes without errors.
|
673
|
+
|
674
|
+
Returns:
|
675
|
+
MigrationTestResults containing individual results for each test case.
|
676
|
+
|
677
|
+
Example:
|
678
|
+
>>> # Using tuples (source, target)
|
679
|
+
>>> results = manager.test_migration(
|
680
|
+
... "User", "1.0.0", "2.0.0",
|
681
|
+
... test_cases=[
|
682
|
+
... ({"name": "Alice"}, {"name": "Alice", "email": "alice@example.com"}),
|
683
|
+
... ({"name": "Bob"}, {"name": "Bob", "email": "bob@example.com"})
|
684
|
+
... ]
|
685
|
+
... )
|
686
|
+
>>> assert results.all_passed
|
687
|
+
>>>
|
688
|
+
>>> # Using MigrationTestCase objects
|
689
|
+
>>> results = manager.test_migration(
|
690
|
+
... "User", "1.0.0", "2.0.0",
|
691
|
+
... test_cases=[
|
692
|
+
... MigrationTestCase(
|
693
|
+
... source={"name": "Alice"},
|
694
|
+
... target={"name": "Alice", "email": "alice@example.com"},
|
695
|
+
... description="Standard user migration"
|
696
|
+
... )
|
697
|
+
... ]
|
698
|
+
... )
|
699
|
+
>>>
|
700
|
+
>>> # Use in pytest
|
701
|
+
>>> def test_user_migration():
|
702
|
+
... results = manager.test_migration("User", "1.0.0", "2.0.0", test_cases)
|
703
|
+
... results.assert_all_passed() # Raises AssertionError with details if failed
|
704
|
+
>>>
|
705
|
+
>>> # Inspect failures
|
706
|
+
>>> if not results.all_passed:
|
707
|
+
... for failure in results.failures:
|
708
|
+
... print(f"Failed: {failure.test_case.description}")
|
709
|
+
... print(f" Error: {failure.error}")
|
710
|
+
""" # noqa: E501
|
711
|
+
results = []
|
712
|
+
|
713
|
+
for test_case_input in test_cases:
|
714
|
+
if isinstance(test_case_input, tuple):
|
715
|
+
test_case = MigrationTestCase(
|
716
|
+
source=test_case_input[0], target=test_case_input[1]
|
717
|
+
)
|
718
|
+
else:
|
719
|
+
test_case = test_case_input
|
720
|
+
|
721
|
+
try:
|
722
|
+
actual = self.migrate_data(
|
723
|
+
test_case.source, name, from_version, to_version
|
724
|
+
)
|
725
|
+
|
726
|
+
if test_case.target is not None:
|
727
|
+
passed = actual == test_case.target
|
728
|
+
error = None if passed else "Output mismatch"
|
729
|
+
else:
|
730
|
+
# Just verify it doesn't crash
|
731
|
+
passed = True
|
732
|
+
error = None
|
733
|
+
|
734
|
+
results.append(
|
735
|
+
MigrationTestResult(
|
736
|
+
test_case=test_case, actual=actual, passed=passed, error=error
|
737
|
+
)
|
738
|
+
)
|
739
|
+
except Exception as e:
|
740
|
+
results.append(
|
741
|
+
MigrationTestResult(
|
742
|
+
test_case=test_case, actual={}, passed=False, error=str(e)
|
743
|
+
)
|
744
|
+
)
|
745
|
+
|
746
|
+
return MigrationTestResults(results)
|
pyrmute/model_version.py
CHANGED
@@ -3,6 +3,8 @@
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from typing import Self
|
5
5
|
|
6
|
+
from .exceptions import InvalidVersionError
|
7
|
+
|
6
8
|
|
7
9
|
@dataclass(frozen=True, order=True)
|
8
10
|
class ModelVersion:
|
@@ -29,19 +31,25 @@ class ModelVersion:
|
|
29
31
|
Parsed Version instance.
|
30
32
|
|
31
33
|
Raises:
|
32
|
-
|
34
|
+
InvalidVersionError: If version string format is invalid.
|
33
35
|
"""
|
34
|
-
parts = version_str.split(".")
|
35
|
-
if len(parts) != 3: # noqa: PLR2004
|
36
|
-
raise ValueError(f"Invalid version format: {version_str}")
|
37
|
-
|
38
36
|
try:
|
39
|
-
|
37
|
+
parts = version_str.split(".")
|
38
|
+
if len(parts) != 3: # noqa: PLR2004
|
39
|
+
raise InvalidVersionError(
|
40
|
+
version_str, "Version must have exactly 3 parts (major.minor.patch)"
|
41
|
+
)
|
42
|
+
|
43
|
+
major, minor, patch = map(int, parts)
|
40
44
|
if major < 0 or minor < 0 or patch < 0:
|
41
|
-
raise
|
45
|
+
raise InvalidVersionError(
|
46
|
+
version_str, "Version parts must be positive integers"
|
47
|
+
)
|
42
48
|
return cls(major, minor, patch)
|
43
49
|
except ValueError as e:
|
44
|
-
raise
|
50
|
+
raise InvalidVersionError(
|
51
|
+
version_str, f"Version parts must be integers: {e}"
|
52
|
+
) from e
|
45
53
|
|
46
54
|
def __str__(self: Self) -> str:
|
47
55
|
"""Return string representation of version.
|
pyrmute/types.py
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
"""Type aliases needed in the package."""
|
2
2
|
|
3
|
+
from __future__ import annotations
|
4
|
+
|
3
5
|
from collections.abc import Callable
|
4
6
|
from typing import Any, TypeAlias, TypeVar
|
5
7
|
|
@@ -9,15 +11,18 @@ from .model_version import ModelVersion
|
|
9
11
|
|
10
12
|
DecoratedBaseModel = TypeVar("DecoratedBaseModel", bound=BaseModel)
|
11
13
|
|
12
|
-
JsonValue: TypeAlias =
|
14
|
+
JsonValue: TypeAlias = (
|
15
|
+
int | float | str | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
|
16
|
+
)
|
13
17
|
JsonSchema: TypeAlias = dict[str, JsonValue]
|
14
|
-
JsonSchemaDefinitions: TypeAlias = dict[str,
|
18
|
+
JsonSchemaDefinitions: TypeAlias = dict[str, JsonValue]
|
15
19
|
JsonSchemaGenerator: TypeAlias = Callable[[type[BaseModel]], JsonSchema]
|
16
20
|
SchemaGenerators: TypeAlias = dict[ModelVersion, JsonSchemaGenerator]
|
17
21
|
|
18
22
|
MigrationData: TypeAlias = dict[str, Any]
|
19
23
|
MigrationFunc: TypeAlias = Callable[[MigrationData], MigrationData]
|
20
24
|
|
25
|
+
|
21
26
|
MigrationKey: TypeAlias = tuple[ModelVersion, ModelVersion]
|
22
27
|
MigrationMap: TypeAlias = dict[MigrationKey, MigrationFunc]
|
23
28
|
|