pyrmute 0.2.0__tar.gz → 0.4.0__tar.gz
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-0.2.0 → pyrmute-0.4.0}/.github/workflows/ci.yml +11 -7
- {pyrmute-0.2.0 → pyrmute-0.4.0}/PKG-INFO +229 -70
- pyrmute-0.4.0/README.md +474 -0
- pyrmute-0.4.0/examples/advanced_features.py +373 -0
- pyrmute-0.4.0/examples/config_file_migration.py +448 -0
- pyrmute-0.4.0/examples/etl_data_import.py +714 -0
- pyrmute-0.4.0/examples/message_queue_consumer.py +534 -0
- pyrmute-0.4.0/examples/ml_inference_pipeline.py +671 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/pyproject.toml +1 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/__init__.py +13 -11
- pyrmute-0.4.0/src/pyrmute/_migration_manager.py +766 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/_schema_manager.py +17 -7
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/_version.py +3 -3
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/migration_testing.py +8 -5
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/model_manager.py +46 -66
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/types.py +11 -4
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute.egg-info/PKG-INFO +229 -70
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute.egg-info/SOURCES.txt +5 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/tests/conftest.py +2 -2
- {pyrmute-0.2.0 → pyrmute-0.4.0}/tests/test_hypothesis.py +23 -23
- pyrmute-0.4.0/tests/test_migration_manager.py +3316 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/tests/test_migration_testing.py +5 -5
- {pyrmute-0.2.0 → pyrmute-0.4.0}/tests/test_model_manager.py +24 -80
- {pyrmute-0.2.0 → pyrmute-0.4.0}/tests/test_schema_manager.py +11 -7
- {pyrmute-0.2.0 → pyrmute-0.4.0}/uv.lock +146 -112
- pyrmute-0.2.0/README.md +0 -316
- pyrmute-0.2.0/src/pyrmute/_migration_manager.py +0 -381
- pyrmute-0.2.0/tests/test_migration_manager.py +0 -1217
- {pyrmute-0.2.0 → pyrmute-0.4.0}/.github/workflows/publish.yml +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/.gitignore +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/LICENSE +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/setup.cfg +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/_registry.py +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/exceptions.py +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/model_diff.py +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/model_version.py +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute/py.typed +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute.egg-info/dependency_links.txt +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute.egg-info/requires.txt +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/src/pyrmute.egg-info/top_level.txt +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/tests/test_model_diff.py +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/tests/test_model_version.py +0 -0
- {pyrmute-0.2.0 → pyrmute-0.4.0}/tests/test_registry.py +0 -0
@@ -23,7 +23,7 @@ jobs:
|
|
23
23
|
runs-on: ubuntu-latest
|
24
24
|
strategy:
|
25
25
|
matrix:
|
26
|
-
python-version: ["3.11", "3.12", "3.13"]
|
26
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
27
27
|
|
28
28
|
steps:
|
29
29
|
- name: Checkout
|
@@ -37,21 +37,25 @@ jobs:
|
|
37
37
|
- name: Set up Python
|
38
38
|
run: uv python install ${{ matrix.python-version }}
|
39
39
|
|
40
|
-
- name:
|
41
|
-
run:
|
40
|
+
- name: Install dependencies
|
41
|
+
run: |
|
42
|
+
if [ "${{ github.event_name }}" = "schedule" ]; then
|
43
|
+
uv sync --all-extras --upgrade
|
44
|
+
else
|
45
|
+
uv sync --all-extras --frozen
|
46
|
+
fi
|
42
47
|
|
43
48
|
- name: Ruff check
|
44
|
-
if: ${{ always() }}
|
45
49
|
run: uv run ruff check
|
46
50
|
|
47
51
|
- name: Ruff format
|
48
|
-
if:
|
52
|
+
if: always()
|
49
53
|
run: uv run ruff format --check
|
50
54
|
|
51
55
|
- name: Check typing with mypy
|
52
|
-
if:
|
56
|
+
if: always()
|
53
57
|
run: uv run mypy src tests
|
54
58
|
|
55
59
|
- name: Run tests
|
56
|
-
if:
|
60
|
+
if: always()
|
57
61
|
run: uv run pytest
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pyrmute
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.4.0
|
4
4
|
Summary: Pydantic model migrations and schemas
|
5
5
|
Author-email: Matt Ferrera <mattferrera@gmail.com>
|
6
6
|
License: MIT
|
@@ -14,6 +14,7 @@ Classifier: Operating System :: POSIX :: Linux
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.11
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
17
18
|
Classifier: Natural Language :: English
|
18
19
|
Requires-Python: >=3.11
|
19
20
|
Description-Content-Type: text/markdown
|
@@ -55,6 +56,28 @@ through multiple versions.
|
|
55
56
|
support for large datasets
|
56
57
|
- **Only one dependency** - Pydantic
|
57
58
|
|
59
|
+
## When to Use pyrmute
|
60
|
+
|
61
|
+
pyrmute excels at handling schema evolution in production systems:
|
62
|
+
|
63
|
+
- **Configuration files** - Upgrade user config files as your CLI/desktop app
|
64
|
+
evolves (`.apprc`, `config.json`, `settings.yaml`)
|
65
|
+
- **Message queues & event streams** - Handle messages from multiple service
|
66
|
+
versions publishing different schemas (Kafka, RabbitMQ, SQS)
|
67
|
+
- **ETL & data imports** - Import CSV/JSON/Excel files exported over years
|
68
|
+
with evolving structures
|
69
|
+
- **ML model serving** - Manage feature schema evolution across model versions
|
70
|
+
and A/B tests
|
71
|
+
- **API versioning** - Support multiple API versions with automatic
|
72
|
+
request/response migration
|
73
|
+
- **Database migrations** - Transparently migrate legacy data on read without
|
74
|
+
downtime
|
75
|
+
- **Data archival** - Process historical data dumps with various schema
|
76
|
+
versions
|
77
|
+
|
78
|
+
See the [examples/](examples/) directory for complete, runnable code
|
79
|
+
demonstrating these patterns.
|
80
|
+
|
58
81
|
## Help
|
59
82
|
|
60
83
|
See [documentation](https://mferrera.github.io/pyrmute/) for complete guides
|
@@ -70,7 +93,7 @@ pip install pyrmute
|
|
70
93
|
|
71
94
|
```python
|
72
95
|
from pydantic import BaseModel
|
73
|
-
from pyrmute import ModelManager,
|
96
|
+
from pyrmute import ModelManager, ModelData
|
74
97
|
|
75
98
|
manager = ModelManager()
|
76
99
|
|
@@ -101,7 +124,7 @@ class UserV3(BaseModel):
|
|
101
124
|
|
102
125
|
# Define how to migrate between versions
|
103
126
|
@manager.migration("User", "1.0.0", "2.0.0")
|
104
|
-
def split_name(data:
|
127
|
+
def split_name(data: ModelData) -> ModelData:
|
105
128
|
parts = data["name"].split(" ", 1)
|
106
129
|
return {
|
107
130
|
"first_name": parts[0],
|
@@ -111,7 +134,7 @@ def split_name(data: MigrationData) -> MigrationData:
|
|
111
134
|
|
112
135
|
|
113
136
|
@manager.migration("User", "2.0.0", "3.0.0")
|
114
|
-
def add_email(data:
|
137
|
+
def add_email(data: ModelData) -> ModelData:
|
115
138
|
return {
|
116
139
|
**data,
|
117
140
|
"email": f"{data['first_name'].lower()}@example.com"
|
@@ -133,6 +156,9 @@ print(current_user)
|
|
133
156
|
```python
|
134
157
|
# See exactly what changed between versions
|
135
158
|
diff = manager.diff("User", "1.0.0", "3.0.0")
|
159
|
+
print(f"Added: {diff.added_fields}")
|
160
|
+
print(f"Removed: {diff.removed_fields}")
|
161
|
+
# Render a changelog to Markdown
|
136
162
|
print(diff.to_markdown(header_depth=4))
|
137
163
|
```
|
138
164
|
|
@@ -228,6 +254,71 @@ results = manager.test_migration(
|
|
228
254
|
assert results.all_passed, f"Migration failed: {results.failures}"
|
229
255
|
```
|
230
256
|
|
257
|
+
### Bidirectional Migrations
|
258
|
+
|
259
|
+
```python
|
260
|
+
# Support both upgrades and downgrades
|
261
|
+
@manager.migration("Config", "2.0.0", "1.0.0")
|
262
|
+
def downgrade_config(data: ModelData) -> ModelData:
|
263
|
+
"""Rollback to v1 format."""
|
264
|
+
return {k: v for k, v in data.items() if k in ["setting1", "setting2"]}
|
265
|
+
|
266
|
+
# Useful for:
|
267
|
+
# - Rolling back deployments
|
268
|
+
# - Normalizing outputs from multiple model versions
|
269
|
+
# - Supporting legacy systems during transitions
|
270
|
+
```
|
271
|
+
|
272
|
+
### Nested Model Migrations
|
273
|
+
|
274
|
+
```python
|
275
|
+
# Automatically migrates nested Pydantic models
|
276
|
+
@manager.model("Address", "1.0.0")
|
277
|
+
class AddressV1(BaseModel):
|
278
|
+
street: str
|
279
|
+
city: str
|
280
|
+
|
281
|
+
@manager.model("Address", "2.0.0")
|
282
|
+
class AddressV2(BaseModel):
|
283
|
+
street: str
|
284
|
+
city: str
|
285
|
+
postal_code: str
|
286
|
+
|
287
|
+
@manager.model("User", "2.0.0")
|
288
|
+
class UserV2(BaseModel):
|
289
|
+
name: str
|
290
|
+
address: AddressV2 # Nested model
|
291
|
+
|
292
|
+
# When migrating User, Address is automatically migrated too
|
293
|
+
@manager.migration("Address", "1.0.0", "2.0.0")
|
294
|
+
def add_postal_code(data: ModelData) -> ModelData:
|
295
|
+
return {**data, "postal_code": "00000"}
|
296
|
+
```
|
297
|
+
|
298
|
+
### Discriminated Unions
|
299
|
+
|
300
|
+
```python
|
301
|
+
from typing import Literal, Union
|
302
|
+
from pydantic import Field
|
303
|
+
|
304
|
+
# Handle complex type hierarchies
|
305
|
+
@manager.model("CreditCard", "1.0.0")
|
306
|
+
class CreditCardV1(BaseModel):
|
307
|
+
type: Literal["credit_card"] = "credit_card"
|
308
|
+
card_number: str
|
309
|
+
|
310
|
+
@manager.model("PayPal", "1.0.0")
|
311
|
+
class PayPalV1(BaseModel):
|
312
|
+
type: Literal["paypal"] = "paypal"
|
313
|
+
email: str
|
314
|
+
|
315
|
+
@manager.model("Payment", "1.0.0")
|
316
|
+
class PaymentV1(BaseModel):
|
317
|
+
method: Union[CreditCardV1, PayPalV1] = Field(discriminator="type")
|
318
|
+
|
319
|
+
# Migrations respect discriminated unions
|
320
|
+
```
|
321
|
+
|
231
322
|
### Export JSON Schemas
|
232
323
|
|
233
324
|
```python
|
@@ -236,8 +327,9 @@ manager.dump_schemas("schemas/")
|
|
236
327
|
# Creates: User_v1.0.0.json, User_v2.0.0.json, User_v3.0.0.json
|
237
328
|
|
238
329
|
# Use separate files with $ref for nested models with 'enable_ref=True'.
|
239
|
-
manager.
|
330
|
+
manager.dump_schemas(
|
240
331
|
"schemas/",
|
332
|
+
separate_definitions=True,
|
241
333
|
ref_template="https://api.example.com/schemas/{model}_v{version}.json"
|
242
334
|
)
|
243
335
|
```
|
@@ -262,79 +354,146 @@ config = manager.migrate({"timeout": 60}, "Config", "1.0.0", "2.0.0")
|
|
262
354
|
# ConfigV2(timeout=60, retries=3)
|
263
355
|
```
|
264
356
|
|
265
|
-
## Real-World
|
357
|
+
## Real-World Examples
|
358
|
+
|
359
|
+
### Configuration File Evolution
|
266
360
|
|
267
361
|
```python
|
268
|
-
|
269
|
-
|
270
|
-
|
362
|
+
# Your CLI tool evolves over time
|
363
|
+
@manager.model("AppConfig", "1.0.0")
|
364
|
+
class AppConfigV1(BaseModel):
|
365
|
+
api_key: str
|
366
|
+
debug: bool = False
|
367
|
+
|
368
|
+
@manager.model("AppConfig", "2.0.0")
|
369
|
+
class AppConfigV2(BaseModel):
|
370
|
+
api_key: str
|
371
|
+
api_endpoint: str = "https://api.example.com"
|
372
|
+
log_level: Literal["DEBUG", "INFO", "ERROR"] = "INFO"
|
373
|
+
|
374
|
+
@manager.migration("AppConfig", "1.0.0", "2.0.0")
|
375
|
+
def upgrade_config(data: dict) -> dict:
|
376
|
+
return {
|
377
|
+
"api_key": data["api_key"],
|
378
|
+
"api_endpoint": "https://api.example.com",
|
379
|
+
"log_level": "DEBUG" if data.get("debug") else "INFO",
|
380
|
+
}
|
271
381
|
|
272
|
-
|
382
|
+
# Load and auto-upgrade user's config file
|
383
|
+
def load_config(config_path: Path) -> AppConfigV2:
|
384
|
+
with open(config_path) as f:
|
385
|
+
data = json.load(f)
|
273
386
|
|
387
|
+
version = data.get("_version", "1.0.0")
|
274
388
|
|
275
|
-
#
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
# API v2: Add customer info
|
284
|
-
@manager.model("Order", "2.0.0")
|
285
|
-
class OrderV2(BaseModel):
|
286
|
-
id: str
|
287
|
-
items: list[str]
|
288
|
-
total: float
|
289
|
-
customer_email: EmailStr
|
290
|
-
|
291
|
-
|
292
|
-
# API v3: Structured items and timestamps
|
293
|
-
@manager.model("Order", "3.0.0")
|
294
|
-
class OrderItemV3(BaseModel):
|
295
|
-
product_id: str
|
296
|
-
quantity: int
|
297
|
-
price: float
|
298
|
-
|
299
|
-
|
300
|
-
@manager.model("Order", "3.0.0")
|
301
|
-
class OrderV3(BaseModel):
|
302
|
-
id: str
|
303
|
-
items: list[OrderItemV3]
|
304
|
-
total: float
|
305
|
-
customer_email: EmailStr
|
306
|
-
created_at: datetime
|
307
|
-
|
308
|
-
|
309
|
-
# Define migrations
|
310
|
-
@manager.migration("Order", "1.0.0", "2.0.0")
|
311
|
-
def add_customer_email(data: MigrationData) -> MigrationData:
|
312
|
-
return {**data, "customer_email": "customer@example.com"}
|
313
|
-
|
314
|
-
|
315
|
-
@manager.migration("Order", "2.0.0", "3.0.0")
|
316
|
-
def structure_items(data: MigrationData) -> MigrationData:
|
317
|
-
# Convert simple strings to structured items
|
318
|
-
structured_items = [
|
319
|
-
{
|
320
|
-
"product_id": item,
|
321
|
-
"quantity": 1,
|
322
|
-
"price": data["total"] / len(data["items"])
|
323
|
-
}
|
324
|
-
for item in data["items"]
|
325
|
-
]
|
326
|
-
return {
|
327
|
-
**data,
|
328
|
-
"items": structured_items,
|
329
|
-
"created_at": datetime.now().isoformat()
|
330
|
-
}
|
389
|
+
# Migrate to current version
|
390
|
+
config = manager.migrate(
|
391
|
+
data,
|
392
|
+
"AppConfig",
|
393
|
+
from_version=version,
|
394
|
+
to_version="2.0.0"
|
395
|
+
)
|
331
396
|
|
332
|
-
#
|
333
|
-
|
334
|
-
|
335
|
-
|
397
|
+
# Save upgraded config with version tag
|
398
|
+
with open(config_path, "w") as f:
|
399
|
+
json.dump({**config.model_dump(), "_version": "2.0.0"}, f, indent=2)
|
400
|
+
|
401
|
+
return config
|
336
402
|
```
|
337
403
|
|
404
|
+
### Message Queue Consumer
|
405
|
+
|
406
|
+
```python
|
407
|
+
# Handle messages from multiple service versions
|
408
|
+
@manager.model("OrderEvent", "1.0.0")
|
409
|
+
class OrderEventV1(BaseModel):
|
410
|
+
order_id: str
|
411
|
+
customer_email: str
|
412
|
+
items: list[dict] # Unstructured
|
413
|
+
|
414
|
+
@manager.model("OrderEvent", "2.0.0")
|
415
|
+
class OrderEventV2(BaseModel):
|
416
|
+
order_id: str
|
417
|
+
customer_email: str
|
418
|
+
items: list[OrderItem] # Structured
|
419
|
+
total: Decimal
|
420
|
+
|
421
|
+
def process_message(message: dict, schema_version: str) -> None:
|
422
|
+
# Migrate to current schema regardless of source version
|
423
|
+
event = manager.migrate(
|
424
|
+
message,
|
425
|
+
"OrderEvent",
|
426
|
+
from_version=schema_version,
|
427
|
+
to_version="2.0.0"
|
428
|
+
)
|
429
|
+
# Process with current schema only
|
430
|
+
fulfill_order(event)
|
431
|
+
```
|
432
|
+
|
433
|
+
### ETL Data Import
|
434
|
+
|
435
|
+
```python
|
436
|
+
# Import historical exports with evolving schemas
|
437
|
+
import csv
|
438
|
+
|
439
|
+
def import_customers(file_path: Path, file_version: str) -> None:
|
440
|
+
with open(file_path) as f:
|
441
|
+
reader = csv.DictReader(f)
|
442
|
+
|
443
|
+
# Stream migration for memory efficiency
|
444
|
+
for customer in manager.migrate_batch_streaming(
|
445
|
+
reader,
|
446
|
+
"Customer",
|
447
|
+
from_version=file_version,
|
448
|
+
to_version="3.0.0",
|
449
|
+
chunk_size=1000
|
450
|
+
):
|
451
|
+
database.save(customer)
|
452
|
+
|
453
|
+
# Handle files from different years
|
454
|
+
import_customers("exports/2022_customers.csv", "1.0.0")
|
455
|
+
import_customers("exports/2023_customers.csv", "2.0.0")
|
456
|
+
import_customers("exports/2024_customers.csv", "3.0.0")
|
457
|
+
```
|
458
|
+
|
459
|
+
### ML Model Serving
|
460
|
+
|
461
|
+
```python
|
462
|
+
# Route requests to appropriate model versions
|
463
|
+
class InferenceService:
|
464
|
+
def predict(self, features: dict, request_version: str) -> BaseModel:
|
465
|
+
# Determine target model version (A/B testing, gradual rollout, etc.)
|
466
|
+
model_version = self.get_model_version(features["user_id"])
|
467
|
+
|
468
|
+
# Migrate request to model's expected format
|
469
|
+
model_input = manager.migrate(
|
470
|
+
features,
|
471
|
+
"PredictionRequest",
|
472
|
+
from_version=request_version,
|
473
|
+
to_version=model_version
|
474
|
+
)
|
475
|
+
|
476
|
+
# Run inference
|
477
|
+
prediction = self.models[model_version].predict(model_input)
|
478
|
+
|
479
|
+
# Normalize output for logging/analytics
|
480
|
+
return manager.migrate(
|
481
|
+
prediction,
|
482
|
+
"PredictionResponse",
|
483
|
+
from_version=model_version,
|
484
|
+
to_version="3.0.0"
|
485
|
+
)
|
486
|
+
```
|
487
|
+
|
488
|
+
See [examples/](examples/) for complete runnable code:
|
489
|
+
- `config_file_migration.py` - CLI/desktop app config file evolution
|
490
|
+
- `message_queue_consumer.py` - Kafka/RabbitMQ/SQS consumer handling multiple
|
491
|
+
schemas
|
492
|
+
- `etl_data_import.py` - CSV/JSON/Excel import pipeline with historical data
|
493
|
+
- `ml_inference_pipeline.py` - ML model serving with feature evolution
|
494
|
+
- `advanced_features.py` - Complex Pydantic features (unions, nested models,
|
495
|
+
validators)
|
496
|
+
|
338
497
|
## Contributing
|
339
498
|
|
340
499
|
For guidance on setting up a development environment and how to make a
|