pyrmute 0.3.0__py3-none-any.whl → 0.5.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.
@@ -0,0 +1,130 @@
1
+ """Schema configuration for customized schema generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, Self
7
+
8
+ from pydantic.json_schema import GenerateJsonSchema
9
+
10
+ if TYPE_CHECKING:
11
+ from .types import JsonSchemaGenerator, JsonSchemaMode
12
+
13
+
14
+ @dataclass
15
+ class SchemaConfig:
16
+ """Configuration for JSON schema generation.
17
+
18
+ This class provides fine-grained control over how Pydantic generates JSON schemas,
19
+ supporting both callable generators and Pydantic's GenerateJsonSchema classes.
20
+
21
+ Attributes:
22
+ schema_generator: Custom schema generator. Can be either:
23
+ - A callable taking (type[BaseModel]) -> JsonSchema
24
+ - A subclass of pydantic.json_schema.GenerateJsonSchema
25
+ mode: Schema generation mode - 'validation' for input validation or
26
+ 'serialization' for output serialization.
27
+ by_alias: Whether to use field aliases in the schema.
28
+ ref_template: Template for JSON schema $ref URIs.
29
+ extra_kwargs: Additional arguments to pass to model_json_schema().
30
+
31
+ Example (Callable Generator):
32
+ >>> def custom_generator(model: type[BaseModel]) -> JsonSchema:
33
+ ... schema = model.model_json_schema()
34
+ ... schema["x-custom"] = "metadata"
35
+ ... return schema
36
+ >>>
37
+ >>> config = SchemaConfig(
38
+ ... schema_generator=custom_generator,
39
+ ... mode="validation"
40
+ ... )
41
+
42
+ Example (GenerateJsonSchema Class):
43
+ >>> from pydantic.json_schema import GenerateJsonSchema
44
+ >>>
45
+ >>> class CustomSchemaGenerator(GenerateJsonSchema):
46
+ ... def generate(
47
+ ... self,
48
+ ... schema: Mapping[str, Any],
49
+ ... mode: JsonSchemaMode = "validation"
50
+ ... ) -> JsonSchema:
51
+ ... json_schema = super().generate(schema, mode=mode)
52
+ ... json_schema["x-custom"] = "metadata"
53
+ ... return json_schema
54
+ >>>
55
+ >>> config = SchemaConfig(
56
+ ... schema_generator=CustomSchemaGenerator,
57
+ ... mode="validation",
58
+ ... by_alias=True
59
+ ... )
60
+ """
61
+
62
+ schema_generator: JsonSchemaGenerator | type[GenerateJsonSchema] | None = None
63
+ mode: JsonSchemaMode = "validation"
64
+ by_alias: bool = True
65
+ ref_template: str = "#/$defs/{model}"
66
+ extra_kwargs: dict[str, Any] = field(default_factory=dict)
67
+
68
+ def merge_with(self: Self, other: SchemaConfig | None) -> SchemaConfig:
69
+ """Merge this config with another, with other taking precedence.
70
+
71
+ Args:
72
+ other: Configuration to merge with (overrides this config).
73
+
74
+ Returns:
75
+ New SchemaConfig with merged values.
76
+ """
77
+ if other is None:
78
+ return self
79
+
80
+ return SchemaConfig(
81
+ schema_generator=other.schema_generator or self.schema_generator,
82
+ mode=other.mode if other.mode != "validation" else self.mode,
83
+ by_alias=other.by_alias if not other.by_alias else self.by_alias,
84
+ ref_template=other.ref_template
85
+ if other.ref_template != "#/$defs/{model}"
86
+ else self.ref_template,
87
+ extra_kwargs={**self.extra_kwargs, **other.extra_kwargs},
88
+ )
89
+
90
+ def to_kwargs(self: Self) -> dict[str, Any]:
91
+ """Convert config to kwargs for model_json_schema().
92
+
93
+ Note: If schema_generator is a callable (JsonSchemaGenerator type), it cannot be
94
+ passed to model_json_schema() and must be handled separately by calling it
95
+ directly.
96
+
97
+ Returns:
98
+ Dictionary of arguments for Pydantic's model_json_schema(). If
99
+ schema_generator is a callable, it will NOT be included.
100
+ """
101
+ kwargs = {
102
+ "mode": self.mode,
103
+ "by_alias": self.by_alias,
104
+ "ref_template": self.ref_template,
105
+ **self.extra_kwargs,
106
+ }
107
+
108
+ # Only add schema_generator if it's a GenerateJsonSchema class
109
+ # Callable generators are handled separately
110
+ if (
111
+ self.schema_generator is not None
112
+ and isinstance(self.schema_generator, type)
113
+ and issubclass(self.schema_generator, GenerateJsonSchema)
114
+ ):
115
+ kwargs["schema_generator"] = self.schema_generator
116
+
117
+ return kwargs
118
+
119
+ def is_callable_generator(self: Self) -> bool:
120
+ """Check if schema_generator is a callable function.
121
+
122
+ Returns:
123
+ True if schema_generator is a callable (not a class).
124
+ """
125
+ if self.schema_generator is None:
126
+ return False
127
+
128
+ return callable(self.schema_generator) and not isinstance(
129
+ self.schema_generator, type
130
+ )
pyrmute/types.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
6
  from dataclasses import dataclass
7
- from typing import Any, TypeAlias, TypeVar
7
+ from typing import Any, Literal, TypeAlias, TypeVar
8
8
 
9
9
  from pydantic import BaseModel
10
10
 
@@ -16,9 +16,10 @@ JsonValue: TypeAlias = (
16
16
  int | float | str | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
17
17
  )
18
18
  JsonSchema: TypeAlias = dict[str, JsonValue]
19
+ JsonSchemaMode = Literal["validation", "serialization"]
19
20
  JsonSchemaDefinitions: TypeAlias = dict[str, JsonValue]
20
21
  JsonSchemaGenerator: TypeAlias = Callable[[type[BaseModel]], JsonSchema]
21
- SchemaGenerators: TypeAlias = dict[ModelVersion, JsonSchemaGenerator]
22
+ SchemaTransformer = Callable[[JsonSchema], JsonSchema]
22
23
 
23
24
  ModelData: TypeAlias = dict[str, Any]
24
25
  MigrationFunc: TypeAlias = Callable[[ModelData], ModelData]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrmute
3
- Version: 0.3.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 Example
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 datetime import datetime
274
- from pydantic import BaseModel, EmailStr
275
- from pyrmute import ModelManager, ModelData
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
- manager = ModelManager()
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
- # API v1: Basic order
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
- # Migrate old orders from your database
338
- old_order = {"id": "123", "items": ["widget", "gadget"], "total": 29.99}
339
- new_order = manager.migrate(old_order, "Order", "1.0.0", "3.0.0")
340
- database.save(new_order)
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
@@ -0,0 +1,18 @@
1
+ pyrmute/__init__.py,sha256=vgq5e3jmr2FHmSnnnf-Kvl4Y6oGra3BDl5p_CaHDYLQ,1234
2
+ pyrmute/_migration_manager.py,sha256=TFws66RsEdKLpvjDDQB1pKgeeyUe5WoutacTaeDsZoE,26154
3
+ pyrmute/_registry.py,sha256=eEhMagcpUeZSPNZlIAuTzXcCkPGHTxfaIfEYEoEiFU8,5680
4
+ pyrmute/_schema_manager.py,sha256=9NmyooG92gcOMOYNLRuC8isGUvivIf_Hp8kdVC8lmu8,18622
5
+ pyrmute/_version.py,sha256=fvHpBU3KZKRinkriKdtAt3crenOyysELF-M9y3ozg3U,704
6
+ pyrmute/exceptions.py,sha256=Q57cUuzzMdkIl5Q0_VyLobpdB0WcrE0ggfC-LBoX2Uo,1681
7
+ pyrmute/migration_testing.py,sha256=fpKT2u7pgPRpswb4PUvbd-fQ3W76svNWvEVYVDmb3Dg,5066
8
+ pyrmute/model_diff.py,sha256=vMa2NTYFqt9E7UYDZH4PQmLcoxQw5Sj-nPpUHB_53Ig,9594
9
+ pyrmute/model_manager.py,sha256=EC3xN4Jve77Fk3xJjC3KrU_sY3Eoae-ucJE8wQ3bO3M,27778
10
+ pyrmute/model_version.py,sha256=ftNDuJlN3S5ZKQK8DKqqwfBDRiz4rGCYn-aJ3n6Zmqk,2025
11
+ pyrmute/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ pyrmute/schema_config.py,sha256=ttM4a1-EUUlxmsol6L4rN_Mse-77Y-yidn0ezojy0uY,4767
13
+ pyrmute/types.py,sha256=rzRxOYfh4WPVR1KoNT3vC3UjuBlTarMnNL6Z1Y5icrw,1237
14
+ pyrmute-0.5.0.dist-info/licenses/LICENSE,sha256=otWInySiZeGwhHqQQ7n7nxM5QBSBe2CzeGEmQDZEz8Q,1119
15
+ pyrmute-0.5.0.dist-info/METADATA,sha256=QOv8NMAZ_sw-k2bFOHXM79XGnPUBArt4PRELq5cUdMY,15169
16
+ pyrmute-0.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
+ pyrmute-0.5.0.dist-info/top_level.txt,sha256=C8QtzqE6yBHkeewSp1QewvsyeHj_VQLYjSa5HLtMiow,8
18
+ pyrmute-0.5.0.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- pyrmute/__init__.py,sha256=vB0WBe3CukMBDnK0XP4qqehbMM4z_TUQMcTVPCUyt6Q,1082
2
- pyrmute/_migration_manager.py,sha256=KregnRUKqF1TC9XIpGAHpQvFlnRTEnp2X6Q2sAay8D4,12489
3
- pyrmute/_registry.py,sha256=iUjMPd6CYgyvWT8PxZqHWBZnsHrX25fOPDi_-k_QDJs,6124
4
- pyrmute/_schema_manager.py,sha256=eun8PTL9Gv1XAMVKmE3tYmjdrcf701-IapUXjb6WDL0,12122
5
- pyrmute/_version.py,sha256=5zTqm8rgXsWYBpB2M3Zw_K1D-aV8wP7NsBLrmMKkrAQ,704
6
- pyrmute/exceptions.py,sha256=Q57cUuzzMdkIl5Q0_VyLobpdB0WcrE0ggfC-LBoX2Uo,1681
7
- pyrmute/migration_testing.py,sha256=dOR8BDzmz4mFAI4hFtDUCEMS8Qc8qqD_iOV0qRai-qM,4967
8
- pyrmute/model_diff.py,sha256=vMa2NTYFqt9E7UYDZH4PQmLcoxQw5Sj-nPpUHB_53Ig,9594
9
- pyrmute/model_manager.py,sha256=e3UKFo79pkseCUFXIzW2_onu3GYjAnY1FR4JR_QF-Gc,24596
10
- pyrmute/model_version.py,sha256=ftNDuJlN3S5ZKQK8DKqqwfBDRiz4rGCYn-aJ3n6Zmqk,2025
11
- pyrmute/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- pyrmute/types.py,sha256=56IH8Rl9AmVh_w3V6PbSSEwaPrBSfc4pYrtcxodvlT0,1187
13
- pyrmute-0.3.0.dist-info/licenses/LICENSE,sha256=otWInySiZeGwhHqQQ7n7nxM5QBSBe2CzeGEmQDZEz8Q,1119
14
- pyrmute-0.3.0.dist-info/METADATA,sha256=jnRgO76ovFaktYKqq5BMf4tpUc_DYO_drET7c1hPVk0,9580
15
- pyrmute-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
- pyrmute-0.3.0.dist-info/top_level.txt,sha256=C8QtzqE6yBHkeewSp1QewvsyeHj_VQLYjSa5HLtMiow,8
17
- pyrmute-0.3.0.dist-info/RECORD,,