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)
ID_FIELD = FieldInfo(annotation=Union[str, NoneType], required=False, default_factory=<lambda>)
ID_TYPE = typing.Optional[str]
class KilnBaseModel(pydantic.main.BaseModel):
 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.
model_config = {'validate_assignment': True}
v: int
id: Optional[str]
path: Optional[pathlib.Path]
created_at: datetime.datetime
created_by: str
model_type: str
51    @computed_field()
52    def model_type(self) -> str:
53        return self.type_name()
@classmethod
def type_name(cls) -> str:
56    @classmethod
57    def type_name(cls) -> str:
58        return snake_case(cls.__name__)
@classmethod
def base_filename(cls) -> str:
61    @classmethod
62    def base_filename(cls) -> str:
63        return cls.type_name() + ".kiln"
@classmethod
def load_from_folder(cls: Type[~T], folderPath: pathlib.Path) -> ~T:
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)
@classmethod
def load_from_file(cls: Type[~T], path: pathlib.Path) -> ~T:
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
def save_to_file(self) -> None:
 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
def build_path(self) -> pathlib.Path | None:
110    def build_path(self) -> Path | None:
111        if self.path is not None:
112            return self.path
113        return None
def max_schema_version(self) -> int:
116    def max_schema_version(self) -> int:
117        return 1
model_fields = {'v': FieldInfo(annotation=int, required=False, default=1), 'id': FieldInfo(annotation=Union[str, NoneType], required=False, default_factory=<lambda>), 'path': FieldInfo(annotation=Union[Path, NoneType], required=False, default=None), 'created_at': FieldInfo(annotation=datetime, required=False, default_factory=builtin_function_or_method), 'created_by': FieldInfo(annotation=str, required=False, default_factory=<lambda>)}
model_computed_fields = {'model_type': ComputedFieldInfo(wrapped_property=<property object>, return_type=<class 'str'>, alias=None, alias_priority=None, title=None, field_title_generator=None, description=None, deprecated=None, examples=None, json_schema_extra=None, repr=True)}
class KilnParentedModel(KilnBaseModel):
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.
KilnParentedModel(**data)
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.

parent: Optional[KilnBaseModel]
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
@classmethod
def relationship_name(cls) -> str:
160    @classmethod
161    def relationship_name(cls) -> str:
162        raise NotImplementedError("Relationship name must be implemented")
@classmethod
def parent_type(cls) -> Type[KilnBaseModel]:
165    @classmethod
166    def parent_type(cls) -> Type[KilnBaseModel]:
167        raise NotImplementedError("Parent type must be implemented")
@model_validator(mode='after')
def check_parent_type(self) -> Self:
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
def build_child_dirname(self) -> pathlib.Path:
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)
def build_path(self) -> pathlib.Path | None:
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        )
@classmethod
def all_children_of_parent_path(cls: Type[~PT], parent_path: pathlib.Path | None) -> list[~PT]:
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
model_config = {'validate_assignment': True}
def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
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.

model_fields = {'v': FieldInfo(annotation=int, required=False, default=1), 'id': FieldInfo(annotation=Union[str, NoneType], required=False, default_factory=<lambda>), 'path': FieldInfo(annotation=Union[Path, NoneType], required=False, default=None), 'created_at': FieldInfo(annotation=datetime, required=False, default_factory=builtin_function_or_method), 'created_by': FieldInfo(annotation=str, required=False, default_factory=<lambda>)}
model_computed_fields = {'model_type': ComputedFieldInfo(wrapped_property=<property object>, return_type=<class 'str'>, alias=None, alias_priority=None, title=None, field_title_generator=None, description=None, deprecated=None, examples=None, json_schema_extra=None, repr=True)}
class KilnParentModel(KilnBaseModel):
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.
@classmethod
def validate_and_save_with_subrelations( cls, data: Dict[str, Any], path: pathlib.Path | None = None, parent: KilnBaseModel | None = None):
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
model_config = {'validate_assignment': True}
model_fields = {'v': FieldInfo(annotation=int, required=False, default=1), 'id': FieldInfo(annotation=Union[str, NoneType], required=False, default_factory=<lambda>), 'path': FieldInfo(annotation=Union[Path, NoneType], required=False, default=None), 'created_at': FieldInfo(annotation=datetime, required=False, default_factory=builtin_function_or_method), 'created_by': FieldInfo(annotation=str, required=False, default_factory=<lambda>)}
model_computed_fields = {'model_type': ComputedFieldInfo(wrapped_property=<property object>, return_type=<class 'str'>, alias=None, alias_priority=None, title=None, field_title_generator=None, description=None, deprecated=None, examples=None, json_schema_extra=None, repr=True)}