kiln_ai.datamodel.basemodel
1import json 2import uuid 3from abc import ABCMeta 4from builtins import classmethod 5from datetime import datetime 6from pathlib import Path 7from typing import ( 8 TYPE_CHECKING, 9 Any, 10 Dict, 11 List, 12 Optional, 13 Self, 14 Type, 15 TypeVar, 16) 17 18from pydantic import ( 19 BaseModel, 20 ConfigDict, 21 Field, 22 ValidationError, 23 computed_field, 24 model_validator, 25) 26from pydantic_core import ErrorDetails 27 28from kiln_ai.utils.config import Config 29from kiln_ai.utils.formatting import snake_case 30 31# ID is a 12 digit random integer string. 32# Should be unique per item, at least inside the context of a parent/child relationship. 33# Use integers to make it easier to type for a search function. 34# Allow none, even though we generate it, because we clear it in the REST API if the object is ephemeral (not persisted to disk) 35ID_FIELD = Field(default_factory=lambda: str(uuid.uuid4().int)[:12]) 36ID_TYPE = Optional[str] 37T = TypeVar("T", bound="KilnBaseModel") 38PT = TypeVar("PT", bound="KilnParentedModel") 39 40 41class KilnBaseModel(BaseModel): 42 model_config = ConfigDict(validate_assignment=True) 43 44 v: int = Field(default=1) # schema_version 45 id: ID_TYPE = ID_FIELD 46 path: Optional[Path] = Field(default=None) 47 created_at: datetime = Field(default_factory=datetime.now) 48 created_by: str = Field(default_factory=lambda: Config.shared().user_id) 49 50 @computed_field() 51 def model_type(self) -> str: 52 return self.type_name() 53 54 # if changing the model name, should keep the original name here for parsing old files 55 @classmethod 56 def type_name(cls) -> str: 57 return snake_case(cls.__name__) 58 59 # used as /obj_folder/base_filename.kiln 60 @classmethod 61 def base_filename(cls) -> str: 62 return cls.type_name() + ".kiln" 63 64 @classmethod 65 def load_from_folder(cls: Type[T], folderPath: Path) -> T: 66 path = folderPath / cls.base_filename() 67 return cls.load_from_file(path) 68 69 @classmethod 70 def load_from_file(cls: Type[T], path: Path) -> T: 71 with open(path, "r") as file: 72 file_data = file.read() 73 # TODO P2 perf: parsing the JSON twice here. 74 # Once for model_type, once for model. Can't call model_validate with parsed json because enum types break; they get strings instead of enums. 75 parsed_json = json.loads(file_data) 76 m = cls.model_validate_json(file_data, strict=True) 77 if not isinstance(m, cls): 78 raise ValueError(f"Loaded model is not of type {cls.__name__}") 79 file_data = None 80 m.path = path 81 if m.v > m.max_schema_version(): 82 raise ValueError( 83 f"Cannot load from file because the schema version is higher than the current version. Upgrade kiln to the latest version. " 84 f"Class: {m.__class__.__name__}, id: {getattr(m, 'id', None)}, path: {path}, " 85 f"version: {m.v}, max version: {m.max_schema_version()}" 86 ) 87 if parsed_json["model_type"] != cls.type_name(): 88 raise ValueError( 89 f"Cannot load from file because the model type is incorrect. Expected {cls.type_name()}, got {parsed_json['model_type']}. " 90 f"Class: {m.__class__.__name__}, id: {getattr(m, 'id', None)}, path: {path}, " 91 f"version: {m.v}, max version: {m.max_schema_version()}" 92 ) 93 return m 94 95 def save_to_file(self) -> None: 96 path = self.build_path() 97 if path is None: 98 raise ValueError( 99 f"Cannot save to file because 'path' is not set. Class: {self.__class__.__name__}, " 100 f"id: {getattr(self, 'id', None)}, path: {path}" 101 ) 102 path.parent.mkdir(parents=True, exist_ok=True) 103 json_data = self.model_dump_json(indent=2, exclude={"path"}) 104 with open(path, "w") as file: 105 file.write(json_data) 106 # save the path so even if something like name changes, the file doesn't move 107 self.path = path 108 109 def build_path(self) -> Path | None: 110 if self.path is not None: 111 return self.path 112 return None 113 114 # increment for breaking changes 115 def max_schema_version(self) -> int: 116 return 1 117 118 119class KilnParentedModel(KilnBaseModel, metaclass=ABCMeta): 120 _parent: KilnBaseModel | None = None 121 122 # workaround to tell typechecker that we support the parent property, even though it's not a stock property 123 if TYPE_CHECKING: 124 parent: KilnBaseModel # type: ignore 125 126 def __init__(self, **data): 127 super().__init__(**data) 128 if "parent" in data: 129 self.parent = data["parent"] 130 131 @property 132 def parent(self) -> Optional[KilnBaseModel]: 133 if self._parent is not None: 134 return self._parent 135 # lazy load parent from path 136 if self.path is None: 137 return None 138 # TODO: this only works with base_filename. If we every support custom names, we need to change this. 139 parent_path = ( 140 self.path.parent.parent.parent 141 / self.__class__.parent_type().base_filename() 142 ) 143 if parent_path is None: 144 return None 145 self._parent = self.__class__.parent_type().load_from_file(parent_path) 146 return self._parent 147 148 @parent.setter 149 def parent(self, value: Optional[KilnBaseModel]): 150 if value is not None: 151 expected_parent_type = self.__class__.parent_type() 152 if not isinstance(value, expected_parent_type): 153 raise ValueError( 154 f"Parent must be of type {expected_parent_type}, but was {type(value)}" 155 ) 156 self._parent = value 157 158 # Dynamically implemented by KilnParentModel method injection 159 @classmethod 160 def relationship_name(cls) -> str: 161 raise NotImplementedError("Relationship name must be implemented") 162 163 # Dynamically implemented by KilnParentModel method injection 164 @classmethod 165 def parent_type(cls) -> Type[KilnBaseModel]: 166 raise NotImplementedError("Parent type must be implemented") 167 168 @model_validator(mode="after") 169 def check_parent_type(self) -> Self: 170 if self._parent is not None: 171 expected_parent_type = self.__class__.parent_type() 172 if not isinstance(self._parent, expected_parent_type): 173 raise ValueError( 174 f"Parent must be of type {expected_parent_type}, but was {type(self._parent)}" 175 ) 176 return self 177 178 def build_child_dirname(self) -> Path: 179 # Default implementation for readable folder names. 180 # {id} - {name}/{type}.kiln 181 if self.id is None: 182 # consider generating an ID here. But if it's been cleared, we've already used this without one so raise for now. 183 raise ValueError("ID is not set - can not save or build path") 184 path = self.id 185 name = getattr(self, "name", None) 186 if name is not None: 187 path = f"{path} - {name[:32]}" 188 return Path(path) 189 190 def build_path(self) -> Path | None: 191 # if specifically loaded from an existing path, keep that no matter what 192 # this ensures the file structure is easy to use with git/version control 193 # and that changes to things like name (which impacts default path) don't leave dangling files 194 if self.path is not None: 195 return self.path 196 # Build a path under parent_folder/relationship/file.kiln 197 if self.parent is None: 198 return None 199 parent_path = self.parent.build_path() 200 if parent_path is None: 201 return None 202 parent_folder = parent_path.parent 203 if parent_folder is None: 204 return None 205 return ( 206 parent_folder 207 / self.__class__.relationship_name() 208 / self.build_child_dirname() 209 / self.__class__.base_filename() 210 ) 211 212 @classmethod 213 def all_children_of_parent_path( 214 cls: Type[PT], parent_path: Path | None 215 ) -> list[PT]: 216 if parent_path is None: 217 # children are disk based. If not saved, they don't exist 218 return [] 219 220 # Determine the parent folder 221 if parent_path.is_file(): 222 parent_folder = parent_path.parent 223 else: 224 parent_folder = parent_path 225 226 parent = cls.parent_type().load_from_file(parent_path) 227 if parent is None: 228 raise ValueError("Parent must be set to load children") 229 230 # Ignore type error: this is abstract base class, but children must implement relationship_name 231 relationship_folder = parent_folder / Path(cls.relationship_name()) # type: ignore 232 233 if not relationship_folder.exists() or not relationship_folder.is_dir(): 234 return [] 235 236 # Collect all /relationship/{id}/{base_filename.kiln} files in the relationship folder 237 children = [] 238 for child_file in relationship_folder.glob(f"**/{cls.base_filename()}"): 239 child = cls.load_from_file(child_file) 240 children.append(child) 241 242 return children 243 244 245# Parent create methods for all child relationships 246# You must pass in parent_of in the subclass definition, defining the child relationships 247class KilnParentModel(KilnBaseModel, metaclass=ABCMeta): 248 @classmethod 249 def _create_child_method( 250 cls, relationship_name: str, child_class: Type[KilnParentedModel] 251 ): 252 def child_method(self) -> list[child_class]: 253 return child_class.all_children_of_parent_path(self.path) 254 255 child_method.__name__ = relationship_name 256 child_method.__annotations__ = {"return": List[child_class]} 257 setattr(cls, relationship_name, child_method) 258 259 @classmethod 260 def _create_parent_methods( 261 cls, targetCls: Type[KilnParentedModel], relationship_name: str 262 ): 263 def parent_class_method() -> Type[KilnParentModel]: 264 return cls 265 266 parent_class_method.__name__ = "parent_type" 267 parent_class_method.__annotations__ = {"return": Type[KilnParentModel]} 268 setattr(targetCls, "parent_type", parent_class_method) 269 270 def relationship_name_method() -> str: 271 return relationship_name 272 273 relationship_name_method.__name__ = "relationship_name" 274 relationship_name_method.__annotations__ = {"return": str} 275 setattr(targetCls, "relationship_name", relationship_name_method) 276 277 @classmethod 278 def __init_subclass__(cls, parent_of: Dict[str, Type[KilnParentedModel]], **kwargs): 279 super().__init_subclass__(**kwargs) 280 cls._parent_of = parent_of 281 for relationship_name, child_class in parent_of.items(): 282 cls._create_child_method(relationship_name, child_class) 283 cls._create_parent_methods(child_class, relationship_name) 284 285 @classmethod 286 def validate_and_save_with_subrelations( 287 cls, 288 data: Dict[str, Any], 289 path: Path | None = None, 290 parent: KilnBaseModel | None = None, 291 ): 292 # Validate first, then save. Don't want error half way through, and partly persisted 293 # TODO P2: save to tmp dir, then move atomically. But need to merge directories so later. 294 cls._validate_nested(data, save=False, path=path, parent=parent) 295 instance = cls._validate_nested(data, save=True, path=path, parent=parent) 296 return instance 297 298 @classmethod 299 def _validate_nested( 300 cls, 301 data: Dict[str, Any], 302 save: bool = False, 303 parent: KilnBaseModel | None = None, 304 path: Path | None = None, 305 ): 306 # Collect all validation errors so we can report them all at once 307 validation_errors = [] 308 309 try: 310 instance = cls.model_validate(data, strict=True) 311 if path is not None: 312 instance.path = path 313 if parent is not None and isinstance(instance, KilnParentedModel): 314 instance.parent = parent 315 if save: 316 instance.save_to_file() 317 except ValidationError as e: 318 instance = None 319 for suberror in e.errors(): 320 validation_errors.append(suberror) 321 322 for key, value_list in data.items(): 323 if key in cls._parent_of: 324 parent_type = cls._parent_of[key] 325 if not isinstance(value_list, list): 326 raise ValueError( 327 f"Expected a list for {key}, but got {type(value_list)}" 328 ) 329 for value_index, value in enumerate(value_list): 330 try: 331 if issubclass(parent_type, KilnParentModel): 332 kwargs = {"data": value, "save": save} 333 if instance is not None: 334 kwargs["parent"] = instance 335 parent_type._validate_nested(**kwargs) 336 elif issubclass(parent_type, KilnParentedModel): 337 # Root node 338 subinstance = parent_type.model_validate(value, strict=True) 339 if instance is not None: 340 subinstance.parent = instance 341 if save: 342 subinstance.save_to_file() 343 else: 344 raise ValueError( 345 f"Invalid type {parent_type}. Should be KilnBaseModel based." 346 ) 347 except ValidationError as e: 348 for suberror in e.errors(): 349 cls._append_loc(suberror, key, value_index) 350 validation_errors.append(suberror) 351 352 if len(validation_errors) > 0: 353 raise ValidationError.from_exception_data( 354 title=f"Validation failed for {cls.__name__}", 355 line_errors=validation_errors, 356 input_type="json", 357 ) 358 359 return instance 360 361 @classmethod 362 def _append_loc( 363 cls, error: ErrorDetails, current_loc: str, value_index: int | None = None 364 ): 365 orig_loc = error["loc"] if "loc" in error else None 366 new_loc: list[str | int] = [current_loc] 367 if value_index is not None: 368 new_loc.append(value_index) 369 if isinstance(orig_loc, tuple): 370 new_loc.extend(list(orig_loc)) 371 elif isinstance(orig_loc, list): 372 new_loc.extend(orig_loc) 373 error["loc"] = tuple(new_loc)
42class KilnBaseModel(BaseModel): 43 model_config = ConfigDict(validate_assignment=True) 44 45 v: int = Field(default=1) # schema_version 46 id: ID_TYPE = ID_FIELD 47 path: Optional[Path] = Field(default=None) 48 created_at: datetime = Field(default_factory=datetime.now) 49 created_by: str = Field(default_factory=lambda: Config.shared().user_id) 50 51 @computed_field() 52 def model_type(self) -> str: 53 return self.type_name() 54 55 # if changing the model name, should keep the original name here for parsing old files 56 @classmethod 57 def type_name(cls) -> str: 58 return snake_case(cls.__name__) 59 60 # used as /obj_folder/base_filename.kiln 61 @classmethod 62 def base_filename(cls) -> str: 63 return cls.type_name() + ".kiln" 64 65 @classmethod 66 def load_from_folder(cls: Type[T], folderPath: Path) -> T: 67 path = folderPath / cls.base_filename() 68 return cls.load_from_file(path) 69 70 @classmethod 71 def load_from_file(cls: Type[T], path: Path) -> T: 72 with open(path, "r") as file: 73 file_data = file.read() 74 # TODO P2 perf: parsing the JSON twice here. 75 # Once for model_type, once for model. Can't call model_validate with parsed json because enum types break; they get strings instead of enums. 76 parsed_json = json.loads(file_data) 77 m = cls.model_validate_json(file_data, strict=True) 78 if not isinstance(m, cls): 79 raise ValueError(f"Loaded model is not of type {cls.__name__}") 80 file_data = None 81 m.path = path 82 if m.v > m.max_schema_version(): 83 raise ValueError( 84 f"Cannot load from file because the schema version is higher than the current version. Upgrade kiln to the latest version. " 85 f"Class: {m.__class__.__name__}, id: {getattr(m, 'id', None)}, path: {path}, " 86 f"version: {m.v}, max version: {m.max_schema_version()}" 87 ) 88 if parsed_json["model_type"] != cls.type_name(): 89 raise ValueError( 90 f"Cannot load from file because the model type is incorrect. Expected {cls.type_name()}, got {parsed_json['model_type']}. " 91 f"Class: {m.__class__.__name__}, id: {getattr(m, 'id', None)}, path: {path}, " 92 f"version: {m.v}, max version: {m.max_schema_version()}" 93 ) 94 return m 95 96 def save_to_file(self) -> None: 97 path = self.build_path() 98 if path is None: 99 raise ValueError( 100 f"Cannot save to file because 'path' is not set. Class: {self.__class__.__name__}, " 101 f"id: {getattr(self, 'id', None)}, path: {path}" 102 ) 103 path.parent.mkdir(parents=True, exist_ok=True) 104 json_data = self.model_dump_json(indent=2, exclude={"path"}) 105 with open(path, "w") as file: 106 file.write(json_data) 107 # save the path so even if something like name changes, the file doesn't move 108 self.path = path 109 110 def build_path(self) -> Path | None: 111 if self.path is not None: 112 return self.path 113 return None 114 115 # increment for breaking changes 116 def max_schema_version(self) -> int: 117 return 1
Usage docs: https://docs.pydantic.dev/2.8/concepts/models/
A base class for creating Pydantic models.
Attributes: __class_vars__: The names of classvars defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The signature for instantiating the model.
__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The pydantic-core schema used to build the SchemaValidator and SchemaSerializer.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to
__args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a `RootModel`.
__pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
__pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
__pydantic_extra__: An instance attribute with the values of extra fields from validation when
`model_config['extra'] == 'allow'`.
__pydantic_fields_set__: An instance attribute with the names of fields explicitly set.
__pydantic_private__: Instance attribute with the values of private attributes set on the model instance.
70 @classmethod 71 def load_from_file(cls: Type[T], path: Path) -> T: 72 with open(path, "r") as file: 73 file_data = file.read() 74 # TODO P2 perf: parsing the JSON twice here. 75 # Once for model_type, once for model. Can't call model_validate with parsed json because enum types break; they get strings instead of enums. 76 parsed_json = json.loads(file_data) 77 m = cls.model_validate_json(file_data, strict=True) 78 if not isinstance(m, cls): 79 raise ValueError(f"Loaded model is not of type {cls.__name__}") 80 file_data = None 81 m.path = path 82 if m.v > m.max_schema_version(): 83 raise ValueError( 84 f"Cannot load from file because the schema version is higher than the current version. Upgrade kiln to the latest version. " 85 f"Class: {m.__class__.__name__}, id: {getattr(m, 'id', None)}, path: {path}, " 86 f"version: {m.v}, max version: {m.max_schema_version()}" 87 ) 88 if parsed_json["model_type"] != cls.type_name(): 89 raise ValueError( 90 f"Cannot load from file because the model type is incorrect. Expected {cls.type_name()}, got {parsed_json['model_type']}. " 91 f"Class: {m.__class__.__name__}, id: {getattr(m, 'id', None)}, path: {path}, " 92 f"version: {m.v}, max version: {m.max_schema_version()}" 93 ) 94 return m
96 def save_to_file(self) -> None: 97 path = self.build_path() 98 if path is None: 99 raise ValueError( 100 f"Cannot save to file because 'path' is not set. Class: {self.__class__.__name__}, " 101 f"id: {getattr(self, 'id', None)}, path: {path}" 102 ) 103 path.parent.mkdir(parents=True, exist_ok=True) 104 json_data = self.model_dump_json(indent=2, exclude={"path"}) 105 with open(path, "w") as file: 106 file.write(json_data) 107 # save the path so even if something like name changes, the file doesn't move 108 self.path = path
120class KilnParentedModel(KilnBaseModel, metaclass=ABCMeta): 121 _parent: KilnBaseModel | None = None 122 123 # workaround to tell typechecker that we support the parent property, even though it's not a stock property 124 if TYPE_CHECKING: 125 parent: KilnBaseModel # type: ignore 126 127 def __init__(self, **data): 128 super().__init__(**data) 129 if "parent" in data: 130 self.parent = data["parent"] 131 132 @property 133 def parent(self) -> Optional[KilnBaseModel]: 134 if self._parent is not None: 135 return self._parent 136 # lazy load parent from path 137 if self.path is None: 138 return None 139 # TODO: this only works with base_filename. If we every support custom names, we need to change this. 140 parent_path = ( 141 self.path.parent.parent.parent 142 / self.__class__.parent_type().base_filename() 143 ) 144 if parent_path is None: 145 return None 146 self._parent = self.__class__.parent_type().load_from_file(parent_path) 147 return self._parent 148 149 @parent.setter 150 def parent(self, value: Optional[KilnBaseModel]): 151 if value is not None: 152 expected_parent_type = self.__class__.parent_type() 153 if not isinstance(value, expected_parent_type): 154 raise ValueError( 155 f"Parent must be of type {expected_parent_type}, but was {type(value)}" 156 ) 157 self._parent = value 158 159 # Dynamically implemented by KilnParentModel method injection 160 @classmethod 161 def relationship_name(cls) -> str: 162 raise NotImplementedError("Relationship name must be implemented") 163 164 # Dynamically implemented by KilnParentModel method injection 165 @classmethod 166 def parent_type(cls) -> Type[KilnBaseModel]: 167 raise NotImplementedError("Parent type must be implemented") 168 169 @model_validator(mode="after") 170 def check_parent_type(self) -> Self: 171 if self._parent is not None: 172 expected_parent_type = self.__class__.parent_type() 173 if not isinstance(self._parent, expected_parent_type): 174 raise ValueError( 175 f"Parent must be of type {expected_parent_type}, but was {type(self._parent)}" 176 ) 177 return self 178 179 def build_child_dirname(self) -> Path: 180 # Default implementation for readable folder names. 181 # {id} - {name}/{type}.kiln 182 if self.id is None: 183 # consider generating an ID here. But if it's been cleared, we've already used this without one so raise for now. 184 raise ValueError("ID is not set - can not save or build path") 185 path = self.id 186 name = getattr(self, "name", None) 187 if name is not None: 188 path = f"{path} - {name[:32]}" 189 return Path(path) 190 191 def build_path(self) -> Path | None: 192 # if specifically loaded from an existing path, keep that no matter what 193 # this ensures the file structure is easy to use with git/version control 194 # and that changes to things like name (which impacts default path) don't leave dangling files 195 if self.path is not None: 196 return self.path 197 # Build a path under parent_folder/relationship/file.kiln 198 if self.parent is None: 199 return None 200 parent_path = self.parent.build_path() 201 if parent_path is None: 202 return None 203 parent_folder = parent_path.parent 204 if parent_folder is None: 205 return None 206 return ( 207 parent_folder 208 / self.__class__.relationship_name() 209 / self.build_child_dirname() 210 / self.__class__.base_filename() 211 ) 212 213 @classmethod 214 def all_children_of_parent_path( 215 cls: Type[PT], parent_path: Path | None 216 ) -> list[PT]: 217 if parent_path is None: 218 # children are disk based. If not saved, they don't exist 219 return [] 220 221 # Determine the parent folder 222 if parent_path.is_file(): 223 parent_folder = parent_path.parent 224 else: 225 parent_folder = parent_path 226 227 parent = cls.parent_type().load_from_file(parent_path) 228 if parent is None: 229 raise ValueError("Parent must be set to load children") 230 231 # Ignore type error: this is abstract base class, but children must implement relationship_name 232 relationship_folder = parent_folder / Path(cls.relationship_name()) # type: ignore 233 234 if not relationship_folder.exists() or not relationship_folder.is_dir(): 235 return [] 236 237 # Collect all /relationship/{id}/{base_filename.kiln} files in the relationship folder 238 children = [] 239 for child_file in relationship_folder.glob(f"**/{cls.base_filename()}"): 240 child = cls.load_from_file(child_file) 241 children.append(child) 242 243 return children
Usage docs: https://docs.pydantic.dev/2.8/concepts/models/
A base class for creating Pydantic models.
Attributes: __class_vars__: The names of classvars defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The signature for instantiating the model.
__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The pydantic-core schema used to build the SchemaValidator and SchemaSerializer.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to
__args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a `RootModel`.
__pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
__pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
__pydantic_extra__: An instance attribute with the values of extra fields from validation when
`model_config['extra'] == 'allow'`.
__pydantic_fields_set__: An instance attribute with the names of fields explicitly set.
__pydantic_private__: Instance attribute with the values of private attributes set on the model instance.
127 def __init__(self, **data): 128 super().__init__(**data) 129 if "parent" in data: 130 self.parent = data["parent"]
Create a new model by parsing and validating input data from keyword arguments.
Raises [ValidationError
][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.
self
is explicitly positional-only to allow self
as a field name.
132 @property 133 def parent(self) -> Optional[KilnBaseModel]: 134 if self._parent is not None: 135 return self._parent 136 # lazy load parent from path 137 if self.path is None: 138 return None 139 # TODO: this only works with base_filename. If we every support custom names, we need to change this. 140 parent_path = ( 141 self.path.parent.parent.parent 142 / self.__class__.parent_type().base_filename() 143 ) 144 if parent_path is None: 145 return None 146 self._parent = self.__class__.parent_type().load_from_file(parent_path) 147 return self._parent
169 @model_validator(mode="after") 170 def check_parent_type(self) -> Self: 171 if self._parent is not None: 172 expected_parent_type = self.__class__.parent_type() 173 if not isinstance(self._parent, expected_parent_type): 174 raise ValueError( 175 f"Parent must be of type {expected_parent_type}, but was {type(self._parent)}" 176 ) 177 return self
179 def build_child_dirname(self) -> Path: 180 # Default implementation for readable folder names. 181 # {id} - {name}/{type}.kiln 182 if self.id is None: 183 # consider generating an ID here. But if it's been cleared, we've already used this without one so raise for now. 184 raise ValueError("ID is not set - can not save or build path") 185 path = self.id 186 name = getattr(self, "name", None) 187 if name is not None: 188 path = f"{path} - {name[:32]}" 189 return Path(path)
191 def build_path(self) -> Path | None: 192 # if specifically loaded from an existing path, keep that no matter what 193 # this ensures the file structure is easy to use with git/version control 194 # and that changes to things like name (which impacts default path) don't leave dangling files 195 if self.path is not None: 196 return self.path 197 # Build a path under parent_folder/relationship/file.kiln 198 if self.parent is None: 199 return None 200 parent_path = self.parent.build_path() 201 if parent_path is None: 202 return None 203 parent_folder = parent_path.parent 204 if parent_folder is None: 205 return None 206 return ( 207 parent_folder 208 / self.__class__.relationship_name() 209 / self.build_child_dirname() 210 / self.__class__.base_filename() 211 )
213 @classmethod 214 def all_children_of_parent_path( 215 cls: Type[PT], parent_path: Path | None 216 ) -> list[PT]: 217 if parent_path is None: 218 # children are disk based. If not saved, they don't exist 219 return [] 220 221 # Determine the parent folder 222 if parent_path.is_file(): 223 parent_folder = parent_path.parent 224 else: 225 parent_folder = parent_path 226 227 parent = cls.parent_type().load_from_file(parent_path) 228 if parent is None: 229 raise ValueError("Parent must be set to load children") 230 231 # Ignore type error: this is abstract base class, but children must implement relationship_name 232 relationship_folder = parent_folder / Path(cls.relationship_name()) # type: ignore 233 234 if not relationship_folder.exists() or not relationship_folder.is_dir(): 235 return [] 236 237 # Collect all /relationship/{id}/{base_filename.kiln} files in the relationship folder 238 children = [] 239 for child_file in relationship_folder.glob(f"**/{cls.base_filename()}"): 240 child = cls.load_from_file(child_file) 241 children.append(child) 242 243 return children
281def init_private_attributes(self: BaseModel, context: Any, /) -> None: 282 """This function is meant to behave like a BaseModel method to initialise private attributes. 283 284 It takes context as an argument since that's what pydantic-core passes when calling it. 285 286 Args: 287 self: The BaseModel instance. 288 context: The context. 289 """ 290 if getattr(self, '__pydantic_private__', None) is None: 291 pydantic_private = {} 292 for name, private_attr in self.__private_attributes__.items(): 293 default = private_attr.get_default() 294 if default is not PydanticUndefined: 295 pydantic_private[name] = default 296 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
248class KilnParentModel(KilnBaseModel, metaclass=ABCMeta): 249 @classmethod 250 def _create_child_method( 251 cls, relationship_name: str, child_class: Type[KilnParentedModel] 252 ): 253 def child_method(self) -> list[child_class]: 254 return child_class.all_children_of_parent_path(self.path) 255 256 child_method.__name__ = relationship_name 257 child_method.__annotations__ = {"return": List[child_class]} 258 setattr(cls, relationship_name, child_method) 259 260 @classmethod 261 def _create_parent_methods( 262 cls, targetCls: Type[KilnParentedModel], relationship_name: str 263 ): 264 def parent_class_method() -> Type[KilnParentModel]: 265 return cls 266 267 parent_class_method.__name__ = "parent_type" 268 parent_class_method.__annotations__ = {"return": Type[KilnParentModel]} 269 setattr(targetCls, "parent_type", parent_class_method) 270 271 def relationship_name_method() -> str: 272 return relationship_name 273 274 relationship_name_method.__name__ = "relationship_name" 275 relationship_name_method.__annotations__ = {"return": str} 276 setattr(targetCls, "relationship_name", relationship_name_method) 277 278 @classmethod 279 def __init_subclass__(cls, parent_of: Dict[str, Type[KilnParentedModel]], **kwargs): 280 super().__init_subclass__(**kwargs) 281 cls._parent_of = parent_of 282 for relationship_name, child_class in parent_of.items(): 283 cls._create_child_method(relationship_name, child_class) 284 cls._create_parent_methods(child_class, relationship_name) 285 286 @classmethod 287 def validate_and_save_with_subrelations( 288 cls, 289 data: Dict[str, Any], 290 path: Path | None = None, 291 parent: KilnBaseModel | None = None, 292 ): 293 # Validate first, then save. Don't want error half way through, and partly persisted 294 # TODO P2: save to tmp dir, then move atomically. But need to merge directories so later. 295 cls._validate_nested(data, save=False, path=path, parent=parent) 296 instance = cls._validate_nested(data, save=True, path=path, parent=parent) 297 return instance 298 299 @classmethod 300 def _validate_nested( 301 cls, 302 data: Dict[str, Any], 303 save: bool = False, 304 parent: KilnBaseModel | None = None, 305 path: Path | None = None, 306 ): 307 # Collect all validation errors so we can report them all at once 308 validation_errors = [] 309 310 try: 311 instance = cls.model_validate(data, strict=True) 312 if path is not None: 313 instance.path = path 314 if parent is not None and isinstance(instance, KilnParentedModel): 315 instance.parent = parent 316 if save: 317 instance.save_to_file() 318 except ValidationError as e: 319 instance = None 320 for suberror in e.errors(): 321 validation_errors.append(suberror) 322 323 for key, value_list in data.items(): 324 if key in cls._parent_of: 325 parent_type = cls._parent_of[key] 326 if not isinstance(value_list, list): 327 raise ValueError( 328 f"Expected a list for {key}, but got {type(value_list)}" 329 ) 330 for value_index, value in enumerate(value_list): 331 try: 332 if issubclass(parent_type, KilnParentModel): 333 kwargs = {"data": value, "save": save} 334 if instance is not None: 335 kwargs["parent"] = instance 336 parent_type._validate_nested(**kwargs) 337 elif issubclass(parent_type, KilnParentedModel): 338 # Root node 339 subinstance = parent_type.model_validate(value, strict=True) 340 if instance is not None: 341 subinstance.parent = instance 342 if save: 343 subinstance.save_to_file() 344 else: 345 raise ValueError( 346 f"Invalid type {parent_type}. Should be KilnBaseModel based." 347 ) 348 except ValidationError as e: 349 for suberror in e.errors(): 350 cls._append_loc(suberror, key, value_index) 351 validation_errors.append(suberror) 352 353 if len(validation_errors) > 0: 354 raise ValidationError.from_exception_data( 355 title=f"Validation failed for {cls.__name__}", 356 line_errors=validation_errors, 357 input_type="json", 358 ) 359 360 return instance 361 362 @classmethod 363 def _append_loc( 364 cls, error: ErrorDetails, current_loc: str, value_index: int | None = None 365 ): 366 orig_loc = error["loc"] if "loc" in error else None 367 new_loc: list[str | int] = [current_loc] 368 if value_index is not None: 369 new_loc.append(value_index) 370 if isinstance(orig_loc, tuple): 371 new_loc.extend(list(orig_loc)) 372 elif isinstance(orig_loc, list): 373 new_loc.extend(orig_loc) 374 error["loc"] = tuple(new_loc)
Usage docs: https://docs.pydantic.dev/2.8/concepts/models/
A base class for creating Pydantic models.
Attributes: __class_vars__: The names of classvars defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The signature for instantiating the model.
__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The pydantic-core schema used to build the SchemaValidator and SchemaSerializer.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to
__args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a `RootModel`.
__pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
__pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
__pydantic_extra__: An instance attribute with the values of extra fields from validation when
`model_config['extra'] == 'allow'`.
__pydantic_fields_set__: An instance attribute with the names of fields explicitly set.
__pydantic_private__: Instance attribute with the values of private attributes set on the model instance.
286 @classmethod 287 def validate_and_save_with_subrelations( 288 cls, 289 data: Dict[str, Any], 290 path: Path | None = None, 291 parent: KilnBaseModel | None = None, 292 ): 293 # Validate first, then save. Don't want error half way through, and partly persisted 294 # TODO P2: save to tmp dir, then move atomically. But need to merge directories so later. 295 cls._validate_nested(data, save=False, path=path, parent=parent) 296 instance = cls._validate_nested(data, save=True, path=path, parent=parent) 297 return instance