diff --git a/libs/core/kiln_ai/datamodel/basemodel.py b/libs/core/kiln_ai/datamodel/basemodel.py index 64ccb30..4010d6f 100644 --- a/libs/core/kiln_ai/datamodel/basemodel.py +++ b/libs/core/kiln_ai/datamodel/basemodel.py @@ -2,7 +2,12 @@ from typing import Optional from pathlib import Path from typing import Type, TypeVar +from abc import ABCMeta, abstractmethod +import uuid + +# ID is a 10 digit hex string +ID_FIELD = Field(default_factory=lambda: uuid.uuid4().hex[:10].upper()) T = TypeVar("T", bound="KilnBaseModel") @@ -32,15 +37,61 @@ def load_from_file(cls: Type[T], path: Path) -> T: return m def save_to_file(self) -> None: - if self.path is None: + path = self.build_path() + if path is None: raise ValueError( f"Cannot save to file because 'path' is not set. Class: {self.__class__.__name__}, " - f"id: {getattr(self, 'id', None)}, path: {self.path}" + f"id: {getattr(self, 'id', None)}, path: {path}" ) + path.parent.mkdir(parents=True, exist_ok=True) json_data = self.model_dump_json(indent=2) - with open(self.path, "w") as file: + with open(path, "w") as file: file.write(json_data) + # save the path so even if something like name changes, the file doesn't move + self.path = path + + def build_path(self) -> Path | None: + if self.path is not None: + return self.path + return None # increment for breaking changes def max_schema_version(self) -> int: return 1 + + +class KilnParentedModel(KilnBaseModel, metaclass=ABCMeta): + id: str = ID_FIELD + parent: Optional[KilnBaseModel] = Field(default=None, exclude=True) + + @abstractmethod + def relationship_name(self) -> str: + pass + + def build_child_filename(self) -> Path: + # Default implementation for readable filenames. + # Can be overridden, but probably shouldn't be. + # {id} - {name}.kiln + path = self.id + name = getattr(self, "name", None) + if name is not None: + path = f"{path} - {name[:32]}" + path = f"{path}.kiln" + return Path(path) + + def build_path(self) -> Path | None: + # if specificaly loaded from an existing path, keep that no matter what + # this ensures the file structure is easy to use with git/version control + # and that changes to things like name (which impacts default path) don't leave dangling files + if self.path is not None: + return self.path + # Build a path under parent_folder/relationship/file.kiln + if self.parent is None: + return None + parent_path = self.parent.build_path() + if parent_path is None: + return None + parent_folder = parent_path.parent + if parent_folder is None: + return None + return parent_folder / self.relationship_name() / self.build_child_filename() diff --git a/libs/core/kiln_ai/datamodel/project.py b/libs/core/kiln_ai/datamodel/project.py index 165e8e8..ab453d8 100644 --- a/libs/core/kiln_ai/datamodel/project.py +++ b/libs/core/kiln_ai/datamodel/project.py @@ -1,4 +1,4 @@ -from .basemodel import KilnBaseModel +from .basemodel import KilnBaseModel, KilnParentedModel from pydantic import Field NAME_REGEX = r"^[A-Za-z0-9 _-]+$" @@ -9,5 +9,8 @@ class Project(KilnBaseModel): name: str = NAME_FIELD -class Task(KilnBaseModel): +class Task(KilnParentedModel): name: str = NAME_FIELD + + def relationship_name(self) -> str: + return "tasks" diff --git a/libs/core/kiln_ai/datamodel/test_basemodel.py b/libs/core/kiln_ai/datamodel/test_basemodel.py index eff6520..b4f132e 100644 --- a/libs/core/kiln_ai/datamodel/test_basemodel.py +++ b/libs/core/kiln_ai/datamodel/test_basemodel.py @@ -1,7 +1,8 @@ import json import pytest -from kiln_ai.datamodel.basemodel import KilnBaseModel +from kiln_ai.datamodel.basemodel import KilnBaseModel, KilnParentedModel from pathlib import Path +from typing import Optional @pytest.fixture @@ -56,3 +57,104 @@ def test_max_schema_version(test_newer_file): def test_type_name(): model = KilnBaseModel() assert model.type == "KilnBaseModel" + + +# Instance of the parented model for abstract methods +class NamedParentedModel(KilnParentedModel): + def relationship_name(self) -> str: + return "tests" + + def build_child_filename(self) -> Path: + return Path("child.kiln") + + +def test_parented_model_path_gen(tmp_path): + parent = KilnBaseModel(path=tmp_path) + child = NamedParentedModel(parent=parent) + child_path = child.build_path() + assert child_path.name == "child.kiln" + assert child_path.parent.name == "tests" + assert child_path.parent.parent == tmp_path.parent + + +# Instance of the parented model for abstract methods, with default name builder +class DefaultParentedModel(KilnParentedModel): + name: Optional[str] = None + + def relationship_name(self) -> str: + return "children" + + +def test_build_default_child_filename(tmp_path): + parent = KilnBaseModel(path=tmp_path) + child = DefaultParentedModel(parent=parent) + child_path = child.build_path() + child_path_without_id = child_path.name[10:] + assert child_path_without_id == ".kiln" + assert child_path.parent.name == "children" + assert child_path.parent.parent == tmp_path.parent + # now with name + child = DefaultParentedModel(parent=parent, name="Name") + child_path = child.build_path() + child_path_without_id = child_path.name[10:] + assert child_path_without_id == " - Name.kiln" + assert child_path.parent.name == "children" + assert child_path.parent.parent == tmp_path.parent + + +def test_serialize_child(tmp_path): + parent = KilnBaseModel(path=tmp_path) + child = DefaultParentedModel(parent=parent, name="Name") + + expected_path = child.build_path() + assert child.path is None + child.save_to_file() + + # ensure we save exact path + assert child.path is not None + assert child.path == expected_path + + # should have made the directory, and saved the file + with open(child.path, "r") as file: + data = json.load(file) + + assert data["v"] == 1 + assert data["name"] == "Name" + assert data["type"] == "DefaultParentedModel" + assert len(data["id"]) == 10 + assert child.path.parent.name == "children" + assert child.path.parent.parent == tmp_path.parent + + # change name, see it serializes, but path stays the same + child.name = "Name2" + child.save_to_file() + assert child.path == expected_path + with open(child.path, "r") as file: + data = json.load(file) + assert data["name"] == "Name2" + + +def test_save_to_set_location(tmp_path): + # Keeps the OG path if parent and path are both set + parent = KilnBaseModel(path=tmp_path) + child_path = tmp_path.parent / "child.kiln" + child = DefaultParentedModel(path=child_path, parent=parent, name="Name") + assert child.build_path() == child_path + + # check file created at child_path, not the default smart path + assert not child_path.exists() + child.save_to_file() + assert child_path.exists() + + # if we don't set the path, use the parent + smartpath + child2 = DefaultParentedModel(parent=parent, name="Name2") + assert child2.build_path().parent.name == "children" + assert child2.build_path().parent.parent == tmp_path.parent + + +def test_parent_without_path(): + # no path from parent or direct path + parent = KilnBaseModel() + child = DefaultParentedModel(parent=parent, name="Name") + with pytest.raises(ValueError): + child.save_to_file() diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..3356c80 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "exclude": ["**/test_*.py"] +} \ No newline at end of file