pyrmute 0.3.0__tar.gz → 0.5.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.3.0 → pyrmute-0.5.0}/.github/workflows/ci.yml +17 -1
- {pyrmute-0.3.0 → pyrmute-0.5.0}/PKG-INFO +235 -66
- pyrmute-0.5.0/README.md +489 -0
- pyrmute-0.5.0/examples/advanced_features.py +373 -0
- pyrmute-0.5.0/examples/config_file_migration.py +448 -0
- pyrmute-0.5.0/examples/etl_data_import.py +714 -0
- pyrmute-0.5.0/examples/message_queue_consumer.py +534 -0
- pyrmute-0.5.0/examples/ml_inference_pipeline.py +671 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/pyproject.toml +6 -4
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/__init__.py +6 -0
- pyrmute-0.5.0/src/pyrmute/_migration_manager.py +766 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/_registry.py +0 -8
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/_schema_manager.py +202 -31
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/_version.py +3 -3
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/migration_testing.py +4 -1
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/model_manager.py +239 -139
- pyrmute-0.5.0/src/pyrmute/schema_config.py +130 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/types.py +3 -2
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute.egg-info/PKG-INFO +235 -66
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute.egg-info/SOURCES.txt +8 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/tests/conftest.py +8 -0
- pyrmute-0.5.0/tests/test_examples.py +164 -0
- pyrmute-0.5.0/tests/test_migration_manager.py +3316 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/tests/test_model_manager.py +581 -15
- {pyrmute-0.3.0 → pyrmute-0.5.0}/tests/test_registry.py +0 -35
- pyrmute-0.5.0/tests/test_schema_config.py +377 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/tests/test_schema_manager.py +633 -18
- pyrmute-0.3.0/README.md +0 -320
- pyrmute-0.3.0/src/pyrmute/_migration_manager.py +0 -381
- pyrmute-0.3.0/tests/test_migration_manager.py +0 -1217
- {pyrmute-0.3.0 → pyrmute-0.5.0}/.github/workflows/publish.yml +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/.gitignore +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/LICENSE +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/setup.cfg +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/exceptions.py +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/model_diff.py +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/model_version.py +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute/py.typed +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute.egg-info/dependency_links.txt +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute.egg-info/requires.txt +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/src/pyrmute.egg-info/top_level.txt +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/tests/test_hypothesis.py +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/tests/test_migration_testing.py +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/tests/test_model_diff.py +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/tests/test_model_version.py +0 -0
- {pyrmute-0.3.0 → pyrmute-0.5.0}/uv.lock +0 -0
@@ -58,4 +58,20 @@ jobs:
|
|
58
58
|
|
59
59
|
- name: Run tests
|
60
60
|
if: always()
|
61
|
-
run: uv run pytest
|
61
|
+
run: uv run pytest --junitxml=junit.xml -o junit_family=legacy
|
62
|
+
|
63
|
+
- name: Run examples
|
64
|
+
if: always()
|
65
|
+
run: uv run pytest -m "not examples"
|
66
|
+
|
67
|
+
- name: Upload coverage
|
68
|
+
uses: codecov/codecov-action@v5
|
69
|
+
if: matrix.python-version == '3.12'
|
70
|
+
with:
|
71
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
72
|
+
|
73
|
+
- name: Upload test results
|
74
|
+
if: matrix.python-version == '3.12'
|
75
|
+
uses: codecov/test-results-action@v1
|
76
|
+
with:
|
77
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pyrmute
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.5.0
|
4
4
|
Summary: Pydantic model migrations and schemas
|
5
5
|
Author-email: Matt Ferrera <mattferrera@gmail.com>
|
6
6
|
License: MIT
|
@@ -56,6 +56,43 @@ through multiple versions.
|
|
56
56
|
support for large datasets
|
57
57
|
- **Only one dependency** - Pydantic
|
58
58
|
|
59
|
+
## When to Use pyrmute
|
60
|
+
|
61
|
+
pyrmute is useful for 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
|
+
|
81
|
+
## When Not to Use
|
82
|
+
|
83
|
+
pyrmute may not be the right choice if you have:
|
84
|
+
|
85
|
+
- **High-throughput systems** - Runtime migration adds latency to hot paths.
|
86
|
+
Use upfront batch migrations instead.
|
87
|
+
- **Multi-language services** - Python-only. Use Protobuf, Avro, or JSON
|
88
|
+
Schema for polyglot architectures.
|
89
|
+
- **Existing schema registries** - Already using Confluent/AWS Glue? Stick
|
90
|
+
with them for compatibility enforcement and governance.
|
91
|
+
- **Stable schemas** - Models rarely change? Traditional migration tools are
|
92
|
+
simpler and more maintainable.
|
93
|
+
- **Database DDL changes** - pyrmute transforms data, not database schemas.
|
94
|
+
Alembic/Flyway or other ORMs may still be needed to alter tables.
|
95
|
+
|
59
96
|
## Help
|
60
97
|
|
61
98
|
See [documentation](https://mferrera.github.io/pyrmute/) for complete guides
|
@@ -232,6 +269,71 @@ results = manager.test_migration(
|
|
232
269
|
assert results.all_passed, f"Migration failed: {results.failures}"
|
233
270
|
```
|
234
271
|
|
272
|
+
### Bidirectional Migrations
|
273
|
+
|
274
|
+
```python
|
275
|
+
# Support both upgrades and downgrades
|
276
|
+
@manager.migration("Config", "2.0.0", "1.0.0")
|
277
|
+
def downgrade_config(data: ModelData) -> ModelData:
|
278
|
+
"""Rollback to v1 format."""
|
279
|
+
return {k: v for k, v in data.items() if k in ["setting1", "setting2"]}
|
280
|
+
|
281
|
+
# Useful for:
|
282
|
+
# - Rolling back deployments
|
283
|
+
# - Normalizing outputs from multiple model versions
|
284
|
+
# - Supporting legacy systems during transitions
|
285
|
+
```
|
286
|
+
|
287
|
+
### Nested Model Migrations
|
288
|
+
|
289
|
+
```python
|
290
|
+
# Automatically migrates nested Pydantic models
|
291
|
+
@manager.model("Address", "1.0.0")
|
292
|
+
class AddressV1(BaseModel):
|
293
|
+
street: str
|
294
|
+
city: str
|
295
|
+
|
296
|
+
@manager.model("Address", "2.0.0")
|
297
|
+
class AddressV2(BaseModel):
|
298
|
+
street: str
|
299
|
+
city: str
|
300
|
+
postal_code: str
|
301
|
+
|
302
|
+
@manager.model("User", "2.0.0")
|
303
|
+
class UserV2(BaseModel):
|
304
|
+
name: str
|
305
|
+
address: AddressV2 # Nested model
|
306
|
+
|
307
|
+
# When migrating User, Address is automatically migrated too
|
308
|
+
@manager.migration("Address", "1.0.0", "2.0.0")
|
309
|
+
def add_postal_code(data: ModelData) -> ModelData:
|
310
|
+
return {**data, "postal_code": "00000"}
|
311
|
+
```
|
312
|
+
|
313
|
+
### Discriminated Unions
|
314
|
+
|
315
|
+
```python
|
316
|
+
from typing import Literal, Union
|
317
|
+
from pydantic import Field
|
318
|
+
|
319
|
+
# Handle complex type hierarchies
|
320
|
+
@manager.model("CreditCard", "1.0.0")
|
321
|
+
class CreditCardV1(BaseModel):
|
322
|
+
type: Literal["credit_card"] = "credit_card"
|
323
|
+
card_number: str
|
324
|
+
|
325
|
+
@manager.model("PayPal", "1.0.0")
|
326
|
+
class PayPalV1(BaseModel):
|
327
|
+
type: Literal["paypal"] = "paypal"
|
328
|
+
email: str
|
329
|
+
|
330
|
+
@manager.model("Payment", "1.0.0")
|
331
|
+
class PaymentV1(BaseModel):
|
332
|
+
method: Union[CreditCardV1, PayPalV1] = Field(discriminator="type")
|
333
|
+
|
334
|
+
# Migrations respect discriminated unions
|
335
|
+
```
|
336
|
+
|
235
337
|
### Export JSON Schemas
|
236
338
|
|
237
339
|
```python
|
@@ -267,79 +369,146 @@ config = manager.migrate({"timeout": 60}, "Config", "1.0.0", "2.0.0")
|
|
267
369
|
# ConfigV2(timeout=60, retries=3)
|
268
370
|
```
|
269
371
|
|
270
|
-
## Real-World
|
372
|
+
## Real-World Examples
|
373
|
+
|
374
|
+
### Configuration File Evolution
|
375
|
+
|
376
|
+
```python
|
377
|
+
# Your CLI tool evolves over time
|
378
|
+
@manager.model("AppConfig", "1.0.0")
|
379
|
+
class AppConfigV1(BaseModel):
|
380
|
+
api_key: str
|
381
|
+
debug: bool = False
|
382
|
+
|
383
|
+
@manager.model("AppConfig", "2.0.0")
|
384
|
+
class AppConfigV2(BaseModel):
|
385
|
+
api_key: str
|
386
|
+
api_endpoint: str = "https://api.example.com"
|
387
|
+
log_level: Literal["DEBUG", "INFO", "ERROR"] = "INFO"
|
388
|
+
|
389
|
+
@manager.migration("AppConfig", "1.0.0", "2.0.0")
|
390
|
+
def upgrade_config(data: dict) -> dict:
|
391
|
+
return {
|
392
|
+
"api_key": data["api_key"],
|
393
|
+
"api_endpoint": "https://api.example.com",
|
394
|
+
"log_level": "DEBUG" if data.get("debug") else "INFO",
|
395
|
+
}
|
396
|
+
|
397
|
+
# Load and auto-upgrade user's config file
|
398
|
+
def load_config(config_path: Path) -> AppConfigV2:
|
399
|
+
with open(config_path) as f:
|
400
|
+
data = json.load(f)
|
401
|
+
|
402
|
+
version = data.get("_version", "1.0.0")
|
403
|
+
|
404
|
+
# Migrate to current version
|
405
|
+
config = manager.migrate(
|
406
|
+
data,
|
407
|
+
"AppConfig",
|
408
|
+
from_version=version,
|
409
|
+
to_version="2.0.0"
|
410
|
+
)
|
411
|
+
|
412
|
+
# Save upgraded config with version tag
|
413
|
+
with open(config_path, "w") as f:
|
414
|
+
json.dump({**config.model_dump(), "_version": "2.0.0"}, f, indent=2)
|
415
|
+
|
416
|
+
return config
|
417
|
+
```
|
418
|
+
|
419
|
+
### Message Queue Consumer
|
271
420
|
|
272
421
|
```python
|
273
|
-
from
|
274
|
-
|
275
|
-
|
422
|
+
# Handle messages from multiple service versions
|
423
|
+
@manager.model("OrderEvent", "1.0.0")
|
424
|
+
class OrderEventV1(BaseModel):
|
425
|
+
order_id: str
|
426
|
+
customer_email: str
|
427
|
+
items: list[dict] # Unstructured
|
428
|
+
|
429
|
+
@manager.model("OrderEvent", "2.0.0")
|
430
|
+
class OrderEventV2(BaseModel):
|
431
|
+
order_id: str
|
432
|
+
customer_email: str
|
433
|
+
items: list[OrderItem] # Structured
|
434
|
+
total: Decimal
|
435
|
+
|
436
|
+
def process_message(message: dict, schema_version: str) -> None:
|
437
|
+
# Migrate to current schema regardless of source version
|
438
|
+
event = manager.migrate(
|
439
|
+
message,
|
440
|
+
"OrderEvent",
|
441
|
+
from_version=schema_version,
|
442
|
+
to_version="2.0.0"
|
443
|
+
)
|
444
|
+
# Process with current schema only
|
445
|
+
fulfill_order(event)
|
446
|
+
```
|
276
447
|
|
277
|
-
|
448
|
+
### ETL Data Import
|
278
449
|
|
450
|
+
```python
|
451
|
+
# Import historical exports with evolving schemas
|
452
|
+
import csv
|
453
|
+
|
454
|
+
def import_customers(file_path: Path, file_version: str) -> None:
|
455
|
+
with open(file_path) as f:
|
456
|
+
reader = csv.DictReader(f)
|
457
|
+
|
458
|
+
# Stream migration for memory efficiency
|
459
|
+
for customer in manager.migrate_batch_streaming(
|
460
|
+
reader,
|
461
|
+
"Customer",
|
462
|
+
from_version=file_version,
|
463
|
+
to_version="3.0.0",
|
464
|
+
chunk_size=1000
|
465
|
+
):
|
466
|
+
database.save(customer)
|
467
|
+
|
468
|
+
# Handle files from different years
|
469
|
+
import_customers("exports/2022_customers.csv", "1.0.0")
|
470
|
+
import_customers("exports/2023_customers.csv", "2.0.0")
|
471
|
+
import_customers("exports/2024_customers.csv", "3.0.0")
|
472
|
+
```
|
279
473
|
|
280
|
-
|
281
|
-
@manager.model("Order", "1.0.0")
|
282
|
-
class OrderV1(BaseModel):
|
283
|
-
id: str
|
284
|
-
items: list[str]
|
285
|
-
total: float
|
286
|
-
|
287
|
-
|
288
|
-
# API v2: Add customer info
|
289
|
-
@manager.model("Order", "2.0.0")
|
290
|
-
class OrderV2(BaseModel):
|
291
|
-
id: str
|
292
|
-
items: list[str]
|
293
|
-
total: float
|
294
|
-
customer_email: EmailStr
|
295
|
-
|
296
|
-
|
297
|
-
# API v3: Structured items and timestamps
|
298
|
-
@manager.model("Order", "3.0.0")
|
299
|
-
class OrderItemV3(BaseModel):
|
300
|
-
product_id: str
|
301
|
-
quantity: int
|
302
|
-
price: float
|
303
|
-
|
304
|
-
|
305
|
-
@manager.model("Order", "3.0.0")
|
306
|
-
class OrderV3(BaseModel):
|
307
|
-
id: str
|
308
|
-
items: list[OrderItemV3]
|
309
|
-
total: float
|
310
|
-
customer_email: EmailStr
|
311
|
-
created_at: datetime
|
312
|
-
|
313
|
-
|
314
|
-
# Define migrations
|
315
|
-
@manager.migration("Order", "1.0.0", "2.0.0")
|
316
|
-
def add_customer_email(data: ModelData) -> ModelData:
|
317
|
-
return {**data, "customer_email": "customer@example.com"}
|
318
|
-
|
319
|
-
|
320
|
-
@manager.migration("Order", "2.0.0", "3.0.0")
|
321
|
-
def structure_items(data: ModelData) -> ModelData:
|
322
|
-
# Convert simple strings to structured items
|
323
|
-
structured_items = [
|
324
|
-
{
|
325
|
-
"product_id": item,
|
326
|
-
"quantity": 1,
|
327
|
-
"price": data["total"] / len(data["items"])
|
328
|
-
}
|
329
|
-
for item in data["items"]
|
330
|
-
]
|
331
|
-
return {
|
332
|
-
**data,
|
333
|
-
"items": structured_items,
|
334
|
-
"created_at": datetime.now().isoformat()
|
335
|
-
}
|
474
|
+
### ML Model Serving
|
336
475
|
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
476
|
+
```python
|
477
|
+
# Route requests to appropriate model versions
|
478
|
+
class InferenceService:
|
479
|
+
def predict(self, features: dict, request_version: str) -> BaseModel:
|
480
|
+
# Determine target model version (A/B testing, gradual rollout, etc.)
|
481
|
+
model_version = self.get_model_version(features["user_id"])
|
482
|
+
|
483
|
+
# Migrate request to model's expected format
|
484
|
+
model_input = manager.migrate(
|
485
|
+
features,
|
486
|
+
"PredictionRequest",
|
487
|
+
from_version=request_version,
|
488
|
+
to_version=model_version
|
489
|
+
)
|
490
|
+
|
491
|
+
# Run inference
|
492
|
+
prediction = self.models[model_version].predict(model_input)
|
493
|
+
|
494
|
+
# Normalize output for logging/analytics
|
495
|
+
return manager.migrate(
|
496
|
+
prediction,
|
497
|
+
"PredictionResponse",
|
498
|
+
from_version=model_version,
|
499
|
+
to_version="3.0.0"
|
500
|
+
)
|
341
501
|
```
|
342
502
|
|
503
|
+
See [examples/](examples/) for complete runnable code:
|
504
|
+
- `config_file_migration.py` - CLI/desktop app config file evolution
|
505
|
+
- `message_queue_consumer.py` - Kafka/RabbitMQ/SQS consumer handling multiple
|
506
|
+
schemas
|
507
|
+
- `etl_data_import.py` - CSV/JSON/Excel import pipeline with historical data
|
508
|
+
- `ml_inference_pipeline.py` - ML model serving with feature evolution
|
509
|
+
- `advanced_features.py` - Complex Pydantic features (unions, nested models,
|
510
|
+
validators)
|
511
|
+
|
343
512
|
## Contributing
|
344
513
|
|
345
514
|
For guidance on setting up a development environment and how to make a
|