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 CHANGED
@@ -5,16 +5,43 @@ and schema management.
5
5
  """
6
6
 
7
7
  from ._version import __version__
8
+ from .exceptions import (
9
+ InvalidVersionError,
10
+ MigrationError,
11
+ ModelNotFoundError,
12
+ VersionedModelError,
13
+ )
14
+ from .migration_testing import (
15
+ MigrationTestCase,
16
+ MigrationTestResult,
17
+ MigrationTestResults,
18
+ )
19
+ from .model_diff import ModelDiff
8
20
  from .model_manager import ModelManager
9
21
  from .model_version import ModelVersion
10
- from .types import JsonSchema, MigrationData, MigrationFunc, ModelMetadata
22
+ from .types import (
23
+ JsonSchema,
24
+ JsonValue,
25
+ MigrationData,
26
+ MigrationFunc,
27
+ ModelMetadata,
28
+ )
11
29
 
12
30
  __all__ = [
31
+ "InvalidVersionError",
13
32
  "JsonSchema",
33
+ "JsonValue",
14
34
  "MigrationData",
35
+ "MigrationError",
15
36
  "MigrationFunc",
37
+ "MigrationTestCase",
38
+ "MigrationTestResult",
39
+ "MigrationTestResults",
40
+ "ModelDiff",
16
41
  "ModelManager",
17
42
  "ModelMetadata",
43
+ "ModelNotFoundError",
18
44
  "ModelVersion",
45
+ "VersionedModelError",
19
46
  "__version__",
20
47
  ]
@@ -1,12 +1,15 @@
1
1
  """Migrations manager."""
2
2
 
3
+ import contextlib
3
4
  from collections.abc import Callable
4
5
  from typing import Any, Self, get_args, get_origin
5
6
 
6
7
  from pydantic import BaseModel
7
8
  from pydantic.fields import FieldInfo
9
+ from pydantic_core import PydanticUndefined
8
10
 
9
11
  from ._registry import Registry
12
+ from .exceptions import MigrationError, ModelNotFoundError
10
13
  from .model_version import ModelVersion
11
14
  from .types import MigrationData, MigrationFunc, ModelName
12
15
 
@@ -14,11 +17,11 @@ from .types import MigrationData, MigrationFunc, ModelName
14
17
  class MigrationManager:
15
18
  """Manager for data migrations between model versions.
16
19
 
17
- Handles registration and execution of migration functions, including
18
- support for nested Pydantic models.
20
+ Handles registration and execution of migration functions, including support for
21
+ nested Pydantic models.
19
22
 
20
23
  Attributes:
21
- registry: Reference to the VersionedModelRegistry.
24
+ registry: Reference to the Registry.
22
25
  """
23
26
 
24
27
  def __init__(self: Self, registry: Registry) -> None:
@@ -87,7 +90,8 @@ class MigrationManager:
87
90
  Migrated data dictionary.
88
91
 
89
92
  Raises:
90
- ValueError: If migration path cannot be found.
93
+ ModelNotFoundError: If model or versions don't exist.
94
+ MigrationError: If migration path cannot be found.
91
95
  """
92
96
  from_ver = (
93
97
  ModelVersion.parse(from_version)
@@ -103,7 +107,7 @@ class MigrationManager:
103
107
  if from_ver == to_ver:
104
108
  return data
105
109
 
106
- path = self._find_migration_path(name, from_ver, to_ver)
110
+ path = self.find_migration_path(name, from_ver, to_ver)
107
111
 
108
112
  current_data = data
109
113
  for i in range(len(path) - 1):
@@ -111,15 +115,41 @@ class MigrationManager:
111
115
 
112
116
  if migration_key in self.registry._migrations[name]:
113
117
  migration_func = self.registry._migrations[name][migration_key]
114
- current_data = migration_func(current_data)
118
+ try:
119
+ current_data = migration_func(current_data)
120
+ except Exception as e:
121
+ raise MigrationError(
122
+ name,
123
+ str(path[i]),
124
+ str(path[i + 1]),
125
+ f"Migration function raised: {type(e).__name__}: {e}",
126
+ ) from e
127
+ elif path[i + 1] in self.registry._backward_compatible_enabled[name]:
128
+ try:
129
+ current_data = self._auto_migrate(
130
+ current_data, name, path[i], path[i + 1]
131
+ )
132
+ except Exception as e:
133
+ raise MigrationError(
134
+ name,
135
+ str(path[i]),
136
+ str(path[i + 1]),
137
+ f"Auto-migration failed: {type(e).__name__}: {e}",
138
+ ) from e
115
139
  else:
116
- current_data = self._auto_migrate(
117
- current_data, name, path[i], path[i + 1]
140
+ raise MigrationError(
141
+ name,
142
+ str(path[i]),
143
+ str(path[i + 1]),
144
+ (
145
+ "No migration path found. Define a migration function or mark "
146
+ "the target version as backward_compatible."
147
+ ),
118
148
  )
119
149
 
120
150
  return current_data
121
151
 
122
- def _find_migration_path(
152
+ def find_migration_path(
123
153
  self: Self,
124
154
  name: ModelName,
125
155
  from_ver: ModelVersion,
@@ -134,11 +164,16 @@ class MigrationManager:
134
164
 
135
165
  Returns:
136
166
  List of versions forming the migration path.
167
+
168
+ Raises:
169
+ ModelNotFoundError: If the model or versions don't exist.
137
170
  """
138
171
  versions = sorted(self.registry.get_versions(name))
139
172
 
140
- if from_ver not in versions or to_ver not in versions:
141
- raise ValueError(f"Invalid version range for {name}")
173
+ if from_ver not in versions:
174
+ raise ModelNotFoundError(name, str(from_ver))
175
+ if to_ver not in versions:
176
+ raise ModelNotFoundError(name, str(to_ver))
142
177
 
143
178
  from_idx = versions.index(from_ver)
144
179
  to_idx = versions.index(to_ver)
@@ -147,6 +182,46 @@ class MigrationManager:
147
182
  return versions[from_idx : to_idx + 1]
148
183
  return versions[to_idx : from_idx + 1][::-1]
149
184
 
185
+ def validate_migration_path(
186
+ self: Self,
187
+ name: ModelName,
188
+ from_ver: ModelVersion,
189
+ to_ver: ModelVersion,
190
+ ) -> None:
191
+ """Validate that a migration path exists and all steps are valid.
192
+
193
+ Args:
194
+ name: Name of the model.
195
+ from_ver: Source version.
196
+ to_ver: Target version.
197
+
198
+ Raises:
199
+ ModelNotFoundError: If the model or versions don't exist.
200
+ MigrationError: If any step in the migration path is invalid.
201
+ """
202
+ path = self.find_migration_path(name, from_ver, to_ver)
203
+
204
+ for i in range(len(path) - 1):
205
+ current_ver = path[i]
206
+ next_ver = path[i + 1]
207
+ migration_key = (current_ver, next_ver)
208
+
209
+ has_explicit = migration_key in self.registry._migrations.get(name, {})
210
+ has_auto = next_ver in self.registry._backward_compatible_enabled.get(
211
+ name, set()
212
+ )
213
+
214
+ if not has_explicit and not has_auto:
215
+ raise MigrationError(
216
+ name,
217
+ str(current_ver),
218
+ str(next_ver),
219
+ (
220
+ "No migration path found. Define a migration function or mark "
221
+ "the target version as backward_compatible."
222
+ ),
223
+ )
224
+
150
225
  def _auto_migrate(
151
226
  self: Self,
152
227
  data: MigrationData,
@@ -156,8 +231,8 @@ class MigrationManager:
156
231
  ) -> MigrationData:
157
232
  """Automatically migrate data when no explicit migration exists.
158
233
 
159
- This method handles nested Pydantic models recursively, migrating
160
- them to their corresponding versions.
234
+ This method handles nested Pydantic models recursively, migrating them to their
235
+ corresponding versions.
161
236
 
162
237
  Args:
163
238
  data: Data dictionary to migrate.
@@ -177,18 +252,25 @@ class MigrationManager:
177
252
  result: MigrationData = {}
178
253
 
179
254
  for field_name, to_field_info in to_fields.items():
180
- if field_name not in data:
181
- continue
182
-
183
- value = data[field_name]
255
+ # Field exists in data, migrate it
256
+ if field_name in data:
257
+ value = data[field_name]
258
+ from_field_info = from_fields.get(field_name)
259
+ result[field_name] = self._migrate_field_value(
260
+ value, from_field_info, to_field_info
261
+ )
184
262
 
185
- # Get corresponding from_field if it exists
186
- from_field_info = from_fields.get(field_name)
263
+ # Field missing from data, use default if available
264
+ elif to_field_info.default is not PydanticUndefined:
265
+ result[field_name] = to_field_info.default
266
+ elif to_field_info.default_factory is not None:
267
+ with contextlib.suppress(Exception):
268
+ result[field_name] = to_field_info.default_factory() # type: ignore
187
269
 
188
- # Migrate the field value (handles nested models)
189
- result[field_name] = self._migrate_field_value(
190
- value, from_field_info, to_field_info
191
- )
270
+ # Migrate all extra data not in the field, too
271
+ for field_name, value in data.items():
272
+ if field_name not in to_fields:
273
+ result[field_name] = value
192
274
 
193
275
  return result
194
276
 
@@ -211,20 +293,17 @@ class MigrationManager:
211
293
  if value is None:
212
294
  return None
213
295
 
214
- # Check if this is a nested Pydantic model
215
296
  if isinstance(value, dict):
216
297
  nested_info = self._extract_nested_model_info(value, from_field, to_field)
217
298
  if nested_info:
218
299
  nested_name, nested_from_ver, nested_to_ver = nested_info
219
300
  return self.migrate(value, nested_name, nested_from_ver, nested_to_ver)
220
301
 
221
- # Try to recursively migrate dict values
222
302
  return {
223
303
  k: self._migrate_field_value(v, from_field, to_field)
224
304
  for k, v in value.items()
225
305
  }
226
306
 
227
- # Handle lists
228
307
  if isinstance(value, list):
229
308
  return [
230
309
  self._migrate_field_value(item, from_field, to_field) for item in value
@@ -249,12 +328,10 @@ class MigrationManager:
249
328
  Tuple of (model_name, from_version, to_version) if this is a
250
329
  versioned nested model, None otherwise.
251
330
  """
252
- # Get the target model type
253
331
  to_model_type = self._get_model_type_from_field(to_field)
254
332
  if not to_model_type or not issubclass(to_model_type, BaseModel):
255
333
  return None
256
334
 
257
- # Check if target model is registered
258
335
  to_info = self.registry.get_model_info(to_model_type)
259
336
  if not to_info:
260
337
  return None
@@ -291,11 +368,9 @@ class MigrationManager:
291
368
  if annotation is None:
292
369
  return None
293
370
 
294
- # Handle direct model types
295
371
  if isinstance(annotation, type) and issubclass(annotation, BaseModel):
296
372
  return annotation
297
373
 
298
- # Handle Optional, List, etc.
299
374
  origin = get_origin(annotation)
300
375
  if origin is not None:
301
376
  args = get_args(annotation)
pyrmute/_registry.py CHANGED
@@ -6,6 +6,7 @@ from typing import Self
6
6
 
7
7
  from pydantic import BaseModel
8
8
 
9
+ from .exceptions import ModelNotFoundError
9
10
  from .model_version import ModelVersion
10
11
  from .types import (
11
12
  DecoratedBaseModel,
@@ -21,8 +22,8 @@ from .types import (
21
22
  class Registry:
22
23
  """Registry for versioned Pydantic models.
23
24
 
24
- Manages the registration and retrieval of versioned models and their
25
- associated metadata.
25
+ Manages the registration and retrieval of versioned models and their associated
26
+ metadata.
26
27
 
27
28
  Attributes:
28
29
  _models: Dictionary mapping model names to version-model mappings.
@@ -39,6 +40,9 @@ class Registry:
39
40
  self._schema_generators: dict[ModelName, SchemaGenerators] = defaultdict(dict)
40
41
  self._model_metadata: dict[type[BaseModel], ModelMetadata] = {}
41
42
  self._ref_enabled: dict[ModelName, set[ModelVersion]] = defaultdict(set)
43
+ self._backward_compatible_enabled: dict[ModelName, set[ModelVersion]] = (
44
+ defaultdict(set)
45
+ )
42
46
 
43
47
  def register(
44
48
  self: Self,
@@ -46,6 +50,7 @@ class Registry:
46
50
  version: str | ModelVersion,
47
51
  schema_generator: JsonSchemaGenerator | None = None,
48
52
  enable_ref: bool = False,
53
+ backward_compatible: bool = False,
49
54
  ) -> Callable[[type[DecoratedBaseModel]], type[DecoratedBaseModel]]:
50
55
  """Register a versioned model.
51
56
 
@@ -53,8 +58,11 @@ class Registry:
53
58
  name: Name of the model.
54
59
  version: Semantic version string or ModelVersion instance.
55
60
  schema_generator: Optional custom schema generator function.
56
- enable_ref: If True, this model can be referenced via $ref in
57
- separate schema files. If False, it will always be inlined.
61
+ enable_ref: If True, this model can be referenced via $ref in separate
62
+ schema files. If False, it will always be inlined.
63
+ backward_compatible: If True, this model does not need a migration function
64
+ to migrate to the next version. If a migration function is defined it
65
+ will use it.
58
66
 
59
67
  Returns:
60
68
  Decorator function for model class.
@@ -74,6 +82,8 @@ class Registry:
74
82
  self._schema_generators[name][ver] = schema_generator
75
83
  if enable_ref:
76
84
  self._ref_enabled[name].add(ver)
85
+ if backward_compatible:
86
+ self._backward_compatible_enabled[name].add(ver)
77
87
  return cls
78
88
 
79
89
  return decorator
@@ -91,12 +101,15 @@ class Registry:
91
101
  Model class for the specified version.
92
102
 
93
103
  Raises:
94
- ValueError: If model or version not found.
104
+ ModelNotFoundError: If model or version not found.
95
105
  """
96
106
  ver = ModelVersion.parse(version) if isinstance(version, str) else version
97
107
 
98
- if name not in self._models or ver not in self._models[name]:
99
- raise ValueError(f"Model {name} v{ver} not found")
108
+ if name not in self._models:
109
+ raise ModelNotFoundError(name)
110
+
111
+ if ver not in self._models[name]:
112
+ raise ModelNotFoundError(name, str(ver))
100
113
 
101
114
  return self._models[name][ver]
102
115
 
@@ -110,10 +123,10 @@ class Registry:
110
123
  Latest version of the model class.
111
124
 
112
125
  Raises:
113
- ValueError: If model not found.
126
+ ModelNotFoundError: If model not found.
114
127
  """
115
128
  if name not in self._models:
116
- raise ValueError(f"Model {name} not found")
129
+ raise ModelNotFoundError(name)
117
130
 
118
131
  latest_version = max(self._models[name].keys())
119
132
  return self._models[name][latest_version]
@@ -128,10 +141,10 @@ class Registry:
128
141
  Sorted list of available versions.
129
142
 
130
143
  Raises:
131
- ValueError: If model not found.
144
+ ModelNotFoundError: If model not found.
132
145
  """
133
146
  if name not in self._models:
134
- raise ValueError(f"Model {name} not found")
147
+ raise ModelNotFoundError(name)
135
148
 
136
149
  return sorted(self._models[name].keys())
137
150
 
@@ -8,6 +8,7 @@ from pydantic import BaseModel
8
8
  from pydantic.fields import FieldInfo
9
9
 
10
10
  from ._registry import Registry
11
+ from .exceptions import ModelNotFoundError
11
12
  from .model_version import ModelVersion
12
13
  from .types import (
13
14
  JsonSchema,
@@ -21,8 +22,8 @@ from .types import (
21
22
  class SchemaManager:
22
23
  """Manager for JSON schema generation and export.
23
24
 
24
- Handles schema generation from Pydantic models with support for
25
- custom schema generators and sub-schema references.
25
+ Handles schema generation from Pydantic models with support for custom schema
26
+ generators and sub-schema references.
26
27
 
27
28
  Attributes:
28
29
  registry: Reference to the Registry.
@@ -73,14 +74,14 @@ class SchemaManager:
73
74
  ) -> JsonSchema:
74
75
  """Get JSON schema with separate definition files for nested models.
75
76
 
76
- This creates a schema where nested Pydantic models are referenced
77
- as external JSON schema files rather than inline definitions.
77
+ This creates a schema where nested Pydantic models are referenced as external
78
+ JSON schema files rather than inline definitions.
78
79
 
79
80
  Args:
80
81
  name: Name of the model.
81
82
  version: Semantic version.
82
- ref_template: Template for generating $ref URLs. Supports {model}
83
- and {version} placeholders.
83
+ ref_template: Template for generating $ref URLs. Supports {model} and
84
+ {version} placeholders.
84
85
  **schema_kwargs: Additional arguments for schema generation.
85
86
 
86
87
  Returns:
@@ -93,8 +94,6 @@ class SchemaManager:
93
94
  ... )
94
95
  """
95
96
  ver = ModelVersion.parse(version) if isinstance(version, str) else version
96
-
97
- # Get the base schema with definitions
98
97
  schema = self.get_schema(name, ver, **schema_kwargs)
99
98
 
100
99
  # Extract and replace definitions with external references
@@ -136,16 +135,15 @@ class SchemaManager:
136
135
  if "$ref" in value:
137
136
  # Extract the definition name from the ref
138
137
  ref = value["$ref"]
139
- if ref.startswith(("#/$defs/", "#/definitions/")):
138
+ if isinstance(ref, str) and ref.startswith(
139
+ ("#/$defs/", "#/definitions/")
140
+ ):
140
141
  def_name = ref.split("/")[-1]
141
142
 
142
- # Try to find the model info for this definition
143
143
  model_info = self._find_model_for_definition(def_name)
144
-
145
144
  if model_info:
146
145
  model_name, model_version = model_info
147
146
 
148
- # Check if this model is enabled for $ref
149
147
  if self.registry.is_ref_enabled(model_name, model_version):
150
148
  # Replace with external reference
151
149
  return {
@@ -156,7 +154,6 @@ class SchemaManager:
156
154
  # Keep as internal reference (will be inlined)
157
155
  return value
158
156
 
159
- # Recursively process nested dictionaries
160
157
  return {k: process_value(v) for k, v in value.items()}
161
158
  if isinstance(value, list):
162
159
  return [process_value(item) for item in value]
@@ -178,7 +175,6 @@ class SchemaManager:
178
175
  Returns:
179
176
  Dictionary of definitions that weren't converted to external refs.
180
177
  """
181
- # Find all internal refs still in the schema
182
178
  internal_refs: set[str] = set()
183
179
 
184
180
  def find_internal_refs(value: dict[str, Any] | list[Any]) -> None:
@@ -195,8 +191,6 @@ class SchemaManager:
195
191
  find_internal_refs(item)
196
192
 
197
193
  find_internal_refs(schema)
198
-
199
- # Return only definitions that are still referenced internally
200
194
  return {k: v for k, v in original_defs.items() if k in internal_refs}
201
195
 
202
196
  def _find_model_for_definition(self: Self, def_name: str) -> ModelMetadata | None:
@@ -208,7 +202,6 @@ class SchemaManager:
208
202
  Returns:
209
203
  Tuple of (model_name, version) if found, None otherwise.
210
204
  """
211
- # Search through all registered models to find matching class name
212
205
  for name, versions in self.registry._models.items():
213
206
  for version, model_class in versions.items():
214
207
  if model_class.__name__ == def_name:
@@ -225,10 +218,10 @@ class SchemaManager:
225
218
  Dictionary mapping versions to their schemas.
226
219
 
227
220
  Raises:
228
- ValueError: If model not found.
221
+ ModelNotFoundError: If model not found.
229
222
  """
230
223
  if name not in self.registry._models:
231
- raise ValueError(f"Model {name} not found")
224
+ raise ModelNotFoundError(name)
232
225
 
233
226
  return {
234
227
  version: self.get_schema(name, version)
@@ -247,8 +240,8 @@ class SchemaManager:
247
240
  Args:
248
241
  output_dir: Directory path for output files.
249
242
  indent: JSON indentation level.
250
- separate_definitions: If True, create separate schema files for
251
- nested models that have enable_ref=True.
243
+ separate_definitions: If True, create separate schema files for nested
244
+ models that have enable_ref=True.
252
245
  ref_template: Template for $ref URLs when separate_definitions=True.
253
246
  Defaults to relative file references if not provided.
254
247
 
@@ -270,19 +263,15 @@ class SchemaManager:
270
263
  output_path.mkdir(parents=True, exist_ok=True)
271
264
 
272
265
  if not separate_definitions:
273
- # Original behavior: inline definitions
274
266
  for name in self.registry._models:
275
267
  for version, schema in self.get_all_schemas(name).items():
276
268
  file_path = output_path / f"{name}_v{version}.json"
277
269
  with open(file_path, "w", encoding="utf-8") as f:
278
270
  json.dump(schema, f, indent=indent)
279
271
  else:
280
- # New behavior: separate definition files for enable_ref=True models
281
- # Default to relative file references
282
272
  if ref_template is None:
283
273
  ref_template = "{model}_v{version}.json"
284
274
 
285
- # First pass: write all schemas
286
275
  for name in self.registry._models:
287
276
  for version in self.registry._models[name]:
288
277
  schema = self.get_schema_with_separate_defs(
@@ -335,11 +324,9 @@ class SchemaManager:
335
324
  if annotation is None:
336
325
  return None
337
326
 
338
- # Handle direct model types
339
327
  if isinstance(annotation, type) and issubclass(annotation, BaseModel):
340
328
  return annotation
341
329
 
342
- # Handle Optional, List, etc.
343
330
  origin = get_origin(annotation)
344
331
  if origin is not None:
345
332
  args = get_args(annotation)
pyrmute/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.0'
32
- __version_tuple__ = version_tuple = (0, 1, 0)
31
+ __version__ = version = '0.2.0'
32
+ __version_tuple__ = version_tuple = (0, 2, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
pyrmute/exceptions.py ADDED
@@ -0,0 +1,55 @@
1
+ """Exceptions."""
2
+
3
+ from typing import Self
4
+
5
+
6
+ class VersionedModelError(Exception):
7
+ """Base exception for all versioned model errors."""
8
+
9
+
10
+ class ModelNotFoundError(VersionedModelError):
11
+ """Raised when a model or version cannot be found in the registry."""
12
+
13
+ def __init__(self: Self, name: str, version: str | None = None) -> None:
14
+ """Initializes ModelNotFoundError."""
15
+ self.name = name
16
+ self.version = version
17
+ if version:
18
+ msg = f"Model '{name}' version '{version}' not found in registry"
19
+ else:
20
+ msg = f"Model '{name}' not found in registry"
21
+ super().__init__(msg)
22
+
23
+
24
+ class MigrationError(VersionedModelError):
25
+ """Raised when a migration fails or cannot be found."""
26
+
27
+ def __init__(
28
+ self: Self,
29
+ name: str,
30
+ from_version: str,
31
+ to_version: str,
32
+ reason: str | None = None,
33
+ ) -> None:
34
+ """Initializes MigrationError."""
35
+ self.name = name
36
+ self.from_version = from_version
37
+ self.to_version = to_version
38
+ self.reason = reason
39
+
40
+ msg = f"Migration failed for '{name}': {from_version} → {to_version}"
41
+ if reason:
42
+ msg += f"\nReason: {reason}"
43
+ super().__init__(msg)
44
+
45
+
46
+ class InvalidVersionError(VersionedModelError):
47
+ """Raised when a version string cannot be parsed."""
48
+
49
+ def __init__(self: Self, version_string: str, reason: str | None = None) -> None:
50
+ """Initializes InvalidVersionError."""
51
+ self.version_string = version_string
52
+ msg = f"Invalid version string: '{version_string}'"
53
+ if reason:
54
+ msg += f"\n{reason}"
55
+ super().__init__(msg)