pyrmute 0.1.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,350 @@
1
+ """Schema manager."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Self, get_args, get_origin
6
+
7
+ from pydantic import BaseModel
8
+ from pydantic.fields import FieldInfo
9
+
10
+ from ._registry import Registry
11
+ from .model_version import ModelVersion
12
+ from .types import (
13
+ JsonSchema,
14
+ JsonSchemaDefinitions,
15
+ JsonValue,
16
+ ModelMetadata,
17
+ ModelName,
18
+ )
19
+
20
+
21
+ class SchemaManager:
22
+ """Manager for JSON schema generation and export.
23
+
24
+ Handles schema generation from Pydantic models with support for
25
+ custom schema generators and sub-schema references.
26
+
27
+ Attributes:
28
+ registry: Reference to the Registry.
29
+ """
30
+
31
+ def __init__(self: Self, registry: Registry) -> None:
32
+ """Initialize the schema manager.
33
+
34
+ Args:
35
+ registry: Registry instance to use.
36
+ """
37
+ self.registry = registry
38
+
39
+ def get_schema(
40
+ self: Self,
41
+ name: ModelName,
42
+ version: str | ModelVersion,
43
+ **schema_kwargs: Any,
44
+ ) -> JsonSchema:
45
+ """Get JSON schema for a specific model version.
46
+
47
+ Args:
48
+ name: Name of the model.
49
+ version: Semantic version.
50
+ **schema_kwargs: Additional arguments for schema generation.
51
+
52
+ Returns:
53
+ JSON schema dictionary.
54
+ """
55
+ ver = ModelVersion.parse(version) if isinstance(version, str) else version
56
+ model = self.registry.get_model(name, ver)
57
+
58
+ if (
59
+ name in self.registry._schema_generators
60
+ and ver in self.registry._schema_generators[name]
61
+ ):
62
+ generator = self.registry._schema_generators[name][ver]
63
+ return generator(model)
64
+
65
+ return model.model_json_schema(**schema_kwargs)
66
+
67
+ def get_schema_with_separate_defs(
68
+ self: Self,
69
+ name: ModelName,
70
+ version: str | ModelVersion,
71
+ ref_template: str = "{model}_v{version}.json",
72
+ **schema_kwargs: Any,
73
+ ) -> JsonSchema:
74
+ """Get JSON schema with separate definition files for nested models.
75
+
76
+ This creates a schema where nested Pydantic models are referenced
77
+ as external JSON schema files rather than inline definitions.
78
+
79
+ Args:
80
+ name: Name of the model.
81
+ version: Semantic version.
82
+ ref_template: Template for generating $ref URLs. Supports {model}
83
+ and {version} placeholders.
84
+ **schema_kwargs: Additional arguments for schema generation.
85
+
86
+ Returns:
87
+ JSON schema dictionary with external $ref for nested models.
88
+
89
+ Example:
90
+ >>> schema = manager.get_schema_with_separate_defs(
91
+ ... "User", "2.0.0",
92
+ ... ref_template="https://example.com/schemas/{model}_v{version}.json"
93
+ ... )
94
+ """
95
+ ver = ModelVersion.parse(version) if isinstance(version, str) else version
96
+
97
+ # Get the base schema with definitions
98
+ schema = self.get_schema(name, ver, **schema_kwargs)
99
+
100
+ # Extract and replace definitions with external references
101
+ if "$defs" in schema or "definitions" in schema:
102
+ defs_key = "$defs" if "$defs" in schema else "definitions"
103
+ definitions: JsonSchemaDefinitions = schema.pop(defs_key, {}) # type: ignore[assignment]
104
+
105
+ # Update all $ref in the schema to point to external files
106
+ schema = self._replace_refs_with_external(schema, definitions, ref_template)
107
+
108
+ # Re-add definitions that weren't converted to external refs
109
+ remaining_defs = self._get_remaining_defs(schema, definitions)
110
+ if remaining_defs:
111
+ schema[defs_key] = remaining_defs
112
+
113
+ return schema
114
+
115
+ def _replace_refs_with_external(
116
+ self: Self,
117
+ schema: JsonSchema,
118
+ definitions: JsonSchemaDefinitions,
119
+ ref_template: str,
120
+ ) -> JsonSchema:
121
+ """Replace internal $ref with external references.
122
+
123
+ Only replaces refs for models that have enable_ref=True.
124
+
125
+ Args:
126
+ schema: The schema to process.
127
+ definitions: Dictionary of definitions to replace.
128
+ ref_template: Template for external references.
129
+
130
+ Returns:
131
+ Updated schema with external references.
132
+ """
133
+
134
+ def process_value(value: JsonValue) -> JsonValue:
135
+ if isinstance(value, dict):
136
+ if "$ref" in value:
137
+ # Extract the definition name from the ref
138
+ ref = value["$ref"]
139
+ if ref.startswith(("#/$defs/", "#/definitions/")):
140
+ def_name = ref.split("/")[-1]
141
+
142
+ # Try to find the model info for this definition
143
+ model_info = self._find_model_for_definition(def_name)
144
+
145
+ if model_info:
146
+ model_name, model_version = model_info
147
+
148
+ # Check if this model is enabled for $ref
149
+ if self.registry.is_ref_enabled(model_name, model_version):
150
+ # Replace with external reference
151
+ return {
152
+ "$ref": ref_template.format(
153
+ model=model_name, version=str(model_version)
154
+ )
155
+ }
156
+ # Keep as internal reference (will be inlined)
157
+ return value
158
+
159
+ # Recursively process nested dictionaries
160
+ return {k: process_value(v) for k, v in value.items()}
161
+ if isinstance(value, list):
162
+ return [process_value(item) for item in value]
163
+ return value
164
+
165
+ return process_value(schema) # type: ignore[return-value]
166
+
167
+ def _get_remaining_defs(
168
+ self: Self,
169
+ schema: JsonSchema,
170
+ original_defs: JsonSchemaDefinitions,
171
+ ) -> JsonSchemaDefinitions:
172
+ """Get definitions that should remain inline.
173
+
174
+ Args:
175
+ schema: The processed schema.
176
+ original_defs: Original definitions.
177
+
178
+ Returns:
179
+ Dictionary of definitions that weren't converted to external refs.
180
+ """
181
+ # Find all internal refs still in the schema
182
+ internal_refs: set[str] = set()
183
+
184
+ def find_internal_refs(value: dict[str, Any] | list[Any]) -> None:
185
+ if isinstance(value, dict):
186
+ if "$ref" in value:
187
+ ref = value["$ref"]
188
+ if ref.startswith(("#/$defs/", "#/definitions/")):
189
+ def_name = ref.split("/")[-1]
190
+ internal_refs.add(def_name)
191
+ for v in value.values():
192
+ find_internal_refs(v)
193
+ elif isinstance(value, list):
194
+ for item in value:
195
+ find_internal_refs(item)
196
+
197
+ find_internal_refs(schema)
198
+
199
+ # Return only definitions that are still referenced internally
200
+ return {k: v for k, v in original_defs.items() if k in internal_refs}
201
+
202
+ def _find_model_for_definition(self: Self, def_name: str) -> ModelMetadata | None:
203
+ """Find the registered model corresponding to a definition name.
204
+
205
+ Args:
206
+ def_name: The definition name from the schema.
207
+
208
+ Returns:
209
+ Tuple of (model_name, version) if found, None otherwise.
210
+ """
211
+ # Search through all registered models to find matching class name
212
+ for name, versions in self.registry._models.items():
213
+ for version, model_class in versions.items():
214
+ if model_class.__name__ == def_name:
215
+ return (name, version)
216
+ return None
217
+
218
+ def get_all_schemas(self: Self, name: ModelName) -> dict[ModelVersion, JsonSchema]:
219
+ """Get all schemas for a model across all versions.
220
+
221
+ Args:
222
+ name: Name of the model.
223
+
224
+ Returns:
225
+ Dictionary mapping versions to their schemas.
226
+
227
+ Raises:
228
+ ValueError: If model not found.
229
+ """
230
+ if name not in self.registry._models:
231
+ raise ValueError(f"Model {name} not found")
232
+
233
+ return {
234
+ version: self.get_schema(name, version)
235
+ for version in self.registry._models[name]
236
+ }
237
+
238
+ def dump_schemas(
239
+ self: Self,
240
+ output_dir: str | Path,
241
+ indent: int = 2,
242
+ separate_definitions: bool = False,
243
+ ref_template: str | None = None,
244
+ ) -> None:
245
+ """Dump all schemas to JSON files.
246
+
247
+ Args:
248
+ output_dir: Directory path for output files.
249
+ indent: JSON indentation level.
250
+ separate_definitions: If True, create separate schema files for
251
+ nested models that have enable_ref=True.
252
+ ref_template: Template for $ref URLs when separate_definitions=True.
253
+ Defaults to relative file references if not provided.
254
+
255
+ Example:
256
+ >>> # Inline definitions (default)
257
+ >>> manager.dump_schemas("schemas/")
258
+ >>>
259
+ >>> # Separate sub-schemas with relative refs (when enable_ref=True models)
260
+ >>> manager.dump_schemas("schemas/", separate_definitions=True)
261
+ >>>
262
+ >>> # Separate sub-schemas with absolute URLs
263
+ >>> manager.dump_schemas(
264
+ ... "schemas/",
265
+ ... separate_definitions=True,
266
+ ... ref_template="https://example.com/schemas/{model}_v{version}.json"
267
+ ... )
268
+ """
269
+ output_path = Path(output_dir)
270
+ output_path.mkdir(parents=True, exist_ok=True)
271
+
272
+ if not separate_definitions:
273
+ # Original behavior: inline definitions
274
+ for name in self.registry._models:
275
+ for version, schema in self.get_all_schemas(name).items():
276
+ file_path = output_path / f"{name}_v{version}.json"
277
+ with open(file_path, "w", encoding="utf-8") as f:
278
+ json.dump(schema, f, indent=indent)
279
+ else:
280
+ # New behavior: separate definition files for enable_ref=True models
281
+ # Default to relative file references
282
+ if ref_template is None:
283
+ ref_template = "{model}_v{version}.json"
284
+
285
+ # First pass: write all schemas
286
+ for name in self.registry._models:
287
+ for version in self.registry._models[name]:
288
+ schema = self.get_schema_with_separate_defs(
289
+ name, version, ref_template
290
+ )
291
+ file_path = output_path / f"{name}_v{version}.json"
292
+ with open(file_path, "w", encoding="utf-8") as f:
293
+ json.dump(schema, f, indent=indent)
294
+
295
+ def get_nested_models(
296
+ self: Self,
297
+ name: ModelName,
298
+ version: str | ModelVersion,
299
+ ) -> list[ModelMetadata]:
300
+ """Get all nested models referenced by a model.
301
+
302
+ Args:
303
+ name: Name of the model.
304
+ version: Semantic version.
305
+
306
+ Returns:
307
+ List of (model_name, model_version) tuples for nested models.
308
+ """
309
+ ver = ModelVersion.parse(version) if isinstance(version, str) else version
310
+ model = self.registry.get_model(name, ver)
311
+
312
+ nested: list[ModelMetadata] = []
313
+
314
+ for field_info in model.model_fields.values():
315
+ model_type = self._get_model_type_from_field(field_info)
316
+ if model_type:
317
+ model_info = self.registry.get_model_info(model_type)
318
+ if model_info and model_info not in nested:
319
+ nested.append(model_info)
320
+
321
+ return nested
322
+
323
+ def _get_model_type_from_field(
324
+ self: Self, field: FieldInfo
325
+ ) -> type[BaseModel] | None:
326
+ """Extract the Pydantic model type from a field.
327
+
328
+ Args:
329
+ field: The field info to extract from.
330
+
331
+ Returns:
332
+ The model type if found, None otherwise.
333
+ """
334
+ annotation = field.annotation
335
+ if annotation is None:
336
+ return None
337
+
338
+ # Handle direct model types
339
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
340
+ return annotation
341
+
342
+ # Handle Optional, List, etc.
343
+ origin = get_origin(annotation)
344
+ if origin is not None:
345
+ args = get_args(annotation)
346
+ for arg in args:
347
+ if isinstance(arg, type) and issubclass(arg, BaseModel):
348
+ return arg
349
+
350
+ return None
pyrmute/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.0'
32
+ __version_tuple__ = version_tuple = (0, 1, 0)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,264 @@
1
+ """Model manager."""
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from typing import Any, Self
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from ._migration_manager import MigrationManager
10
+ from ._registry import Registry
11
+ from ._schema_manager import SchemaManager
12
+ from .model_version import ModelVersion
13
+ from .types import (
14
+ DecoratedBaseModel,
15
+ JsonSchema,
16
+ JsonSchemaGenerator,
17
+ MigrationData,
18
+ MigrationFunc,
19
+ ModelMetadata,
20
+ )
21
+
22
+
23
+ class ModelManager:
24
+ """High-level interface for versioned model management.
25
+
26
+ Provides a unified API for model registration, migration, and schema
27
+ management.
28
+
29
+ Attributes:
30
+ registry: Registry instance.
31
+ migration_manager: MigrationManager instance.
32
+ schema_manager: SchemaManager instance.
33
+
34
+ Example:
35
+ >>> manager = ModelManager()
36
+ >>>
37
+ >>> @manager.model("User", "1.0.0")
38
+ ... class UserV1(BaseModel):
39
+ ... name: str
40
+ >>>
41
+ >>> @manager.model("User", "2.0.0")
42
+ ... class UserV2(BaseModel):
43
+ ... name: str
44
+ ... email: str
45
+ >>>
46
+ >>> @manager.migration("User", "1.0.0", "2.0.0")
47
+ ... def migrate(data: MigrationData) -> MigrationData:
48
+ ... return {**data, "email": "unknown@example.com"}
49
+ """
50
+
51
+ def __init__(self: Self) -> None:
52
+ """Initialize the versioned model manager."""
53
+ self.registry = Registry()
54
+ self.migration_manager = MigrationManager(self.registry)
55
+ self.schema_manager = SchemaManager(self.registry)
56
+
57
+ def model(
58
+ self: Self,
59
+ name: str,
60
+ version: str | ModelVersion,
61
+ schema_generator: JsonSchemaGenerator | None = None,
62
+ enable_ref: bool = False,
63
+ ) -> Callable[[type[DecoratedBaseModel]], type[DecoratedBaseModel]]:
64
+ """Register a versioned model.
65
+
66
+ Args:
67
+ name: Name of the model.
68
+ version: Semantic version.
69
+ schema_generator: Optional custom schema generator.
70
+ enable_ref: If True, this model can be referenced via $ref in
71
+ separate schema files. If False, it will always be inlined.
72
+
73
+ Returns:
74
+ Decorator function for model class.
75
+
76
+ Example:
77
+ >>> # Model that will be inlined (default)
78
+ >>> @manager.model("Address", "1.0.0")
79
+ ... class AddressV1(BaseModel):
80
+ ... street: str
81
+ >>>
82
+ >>> # Model that can be a separate schema with $ref
83
+ >>> @manager.model("City", "1.0.0", enable_ref=True)
84
+ ... class CityV1(BaseModel):
85
+ ... city: City
86
+ """
87
+ return self.registry.register(name, version, schema_generator, enable_ref)
88
+
89
+ def migration(
90
+ self: Self,
91
+ name: str,
92
+ from_version: str | ModelVersion,
93
+ to_version: str | ModelVersion,
94
+ ) -> Callable[[MigrationFunc], MigrationFunc]:
95
+ """Register a migration function.
96
+
97
+ Args:
98
+ name: Name of the model.
99
+ from_version: Source version.
100
+ to_version: Target version.
101
+
102
+ Returns:
103
+ Decorator function for migration function.
104
+ """
105
+ return self.migration_manager.register_migration(name, from_version, to_version)
106
+
107
+ def get(
108
+ self: Self, name: str, version: str | ModelVersion | None = None
109
+ ) -> type[BaseModel]:
110
+ """Get a model by name and version.
111
+
112
+ Args:
113
+ name: Name of the model.
114
+ version: Semantic version (returns latest if None).
115
+
116
+ Returns:
117
+ Model class.
118
+ """
119
+ if version is None:
120
+ return self.registry.get_latest(name)
121
+ return self.registry.get_model(name, version)
122
+
123
+ def migrate(
124
+ self: Self,
125
+ data: MigrationData,
126
+ name: str,
127
+ from_version: str | ModelVersion,
128
+ to_version: str | ModelVersion,
129
+ ) -> BaseModel:
130
+ """Migrate data between versions.
131
+
132
+ Args:
133
+ data: Data dictionary or BaseModel to migrate.
134
+ name: Name of the model.
135
+ from_version: Source version.
136
+ to_version: Target version.
137
+
138
+ Returns:
139
+ Migrated BaseModel.
140
+ """
141
+ migrated_data = self.migration_manager.migrate(
142
+ data, name, from_version, to_version
143
+ )
144
+ target_model = self.get(name, to_version)
145
+ return target_model.model_validate(migrated_data)
146
+
147
+ def get_schema(
148
+ self: Self,
149
+ name: str,
150
+ version: str | ModelVersion,
151
+ **kwargs: Any,
152
+ ) -> JsonSchema:
153
+ """Get JSON schema for a specific version.
154
+
155
+ Args:
156
+ name: Name of the model.
157
+ version: Semantic version.
158
+ **kwargs: Additional schema generation arguments.
159
+
160
+ Returns:
161
+ JSON schema dictionary.
162
+ """
163
+ return self.schema_manager.get_schema(name, version, **kwargs)
164
+
165
+ def list_models(self: Self) -> list[str]:
166
+ """Get list of all registered models.
167
+
168
+ Returns:
169
+ List of model names.
170
+ """
171
+ return self.registry.list_models()
172
+
173
+ def list_versions(self: Self, name: str) -> list[ModelVersion]:
174
+ """Get all versions for a model.
175
+
176
+ Args:
177
+ name: Name of the model.
178
+
179
+ Returns:
180
+ Sorted list of versions.
181
+ """
182
+ return self.registry.get_versions(name)
183
+
184
+ def dump_schemas(
185
+ self: Self,
186
+ output_dir: str | Path,
187
+ indent: int = 2,
188
+ separate_definitions: bool = False,
189
+ ref_template: str | None = None,
190
+ ) -> None:
191
+ """Export all schemas to JSON files.
192
+
193
+ Args:
194
+ output_dir: Directory path for output.
195
+ indent: JSON indentation level.
196
+ separate_definitions: If True, create separate schema files for
197
+ nested models and use $ref to reference them.
198
+ ref_template: Template for $ref URLs when separate_definitions=True.
199
+ Defaults to relative file references if not provided.
200
+
201
+ Example:
202
+ >>> # Inline definitions (default)
203
+ >>> manager.dump_schemas("schemas/")
204
+ >>>
205
+ >>> # Separate sub-schemas with relative refs
206
+ >>> manager.dump_schemas("schemas/", separate_definitions=True)
207
+ >>>
208
+ >>> # Separate sub-schemas with absolute URLs
209
+ >>> manager.dump_schemas(
210
+ ... "schemas/",
211
+ ... separate_definitions=True,
212
+ ... ref_template="https://example.com/schemas/{model}_v{version}.json"
213
+ ... )
214
+ """
215
+ self.schema_manager.dump_schemas(
216
+ output_dir, indent, separate_definitions, ref_template
217
+ )
218
+
219
+ def dump_schemas_with_refs(
220
+ self: Self,
221
+ output_dir: str | Path,
222
+ ref_template: str | None = None,
223
+ indent: int = 2,
224
+ ) -> None:
225
+ """Export schemas with separate files for nested models.
226
+
227
+ This is a convenience method that calls dump_schemas with
228
+ separate_definitions=True.
229
+
230
+ Args:
231
+ output_dir: Directory path for output.
232
+ ref_template: Template for $ref URLs. Supports {model} and
233
+ {version} placeholders. Defaults to relative file refs.
234
+ indent: JSON indentation level.
235
+
236
+ Example:
237
+ >>> # Relative file references (default)
238
+ >>> manager.dump_schemas_with_refs("schemas/")
239
+ >>>
240
+ >>> # Absolute URL references
241
+ >>> manager.dump_schemas_with_refs(
242
+ ... "schemas/",
243
+ ... ref_template="https://example.com/schemas/{model}_v{version}.json"
244
+ ... )
245
+ """
246
+ self.schema_manager.dump_schemas(
247
+ output_dir, indent, separate_definitions=True, ref_template=ref_template
248
+ )
249
+
250
+ def get_nested_models(
251
+ self: Self,
252
+ name: str,
253
+ version: str | ModelVersion,
254
+ ) -> list[ModelMetadata]:
255
+ """Get all nested models used by a model.
256
+
257
+ Args:
258
+ name: Name of the model.
259
+ version: Semantic version.
260
+
261
+ Returns:
262
+ List of (model_name, version) tuples for nested models.
263
+ """
264
+ return self.schema_manager.get_nested_models(name, version)
@@ -0,0 +1,60 @@
1
+ """Models the version provided to managers."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Self
5
+
6
+
7
+ @dataclass(frozen=True, order=True)
8
+ class ModelVersion:
9
+ """Semantic version representation.
10
+
11
+ Attributes:
12
+ major: Major version number (breaking changes).
13
+ minor: Minor version number (backward-compatible features).
14
+ patch: Patch version number (backward-compatible fixes).
15
+ """
16
+
17
+ major: int
18
+ minor: int
19
+ patch: int
20
+
21
+ @classmethod
22
+ def parse(cls, version_str: str) -> Self:
23
+ """Parse a semantic version string.
24
+
25
+ Args:
26
+ version_str: Version string in format "major.minor.patch".
27
+
28
+ Returns:
29
+ Parsed Version instance.
30
+
31
+ Raises:
32
+ ValueError: If version string format is invalid.
33
+ """
34
+ parts = version_str.split(".")
35
+ if len(parts) != 3: # noqa: PLR2004
36
+ raise ValueError(f"Invalid version format: {version_str}")
37
+
38
+ try:
39
+ major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
40
+ if major < 0 or minor < 0 or patch < 0:
41
+ raise ValueError(f"Invalid version format: {version_str}")
42
+ return cls(major, minor, patch)
43
+ except ValueError as e:
44
+ raise ValueError(f"Invalid version format: {version_str}") from e
45
+
46
+ def __str__(self: Self) -> str:
47
+ """Return string representation of version.
48
+
49
+ Returns:
50
+ Version string in format "major.minor.patch".
51
+ """
52
+ return f"{self.major}.{self.minor}.{self.patch}"
53
+
54
+ def __repr__(self: Self) -> str:
55
+ """Return detailed string representation.
56
+
57
+ Returns:
58
+ Detailed version representation.
59
+ """
60
+ return f"ModelVersion({self.major}, {self.minor}, {self.patch})"