Skip to content

Commit

Permalink
Add a parenting structure: items can belong to another. They will aut…
Browse files Browse the repository at this point in the history
…omatically be saved to a subdirectory, and don't need an explicit path.

Smart names make it easy to use "UUID - Name.kiln"

Structured in relationship folders (/tasks/1322.kiln, tasks/3445.kiln)

Setup to work with git/VC: files don't move once created

Ignore type checks in test files
  • Loading branch information
scosman committed Aug 20, 2024
1 parent a62797b commit 61ac967
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 6 deletions.
57 changes: 54 additions & 3 deletions libs/core/kiln_ai/datamodel/basemodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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()
7 changes: 5 additions & 2 deletions libs/core/kiln_ai/datamodel/project.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .basemodel import KilnBaseModel
from .basemodel import KilnBaseModel, KilnParentedModel
from pydantic import Field

NAME_REGEX = r"^[A-Za-z0-9 _-]+$"
Expand All @@ -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"
104 changes: 103 additions & 1 deletion libs/core/kiln_ai/datamodel/test_basemodel.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
3 changes: 3 additions & 0 deletions pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"exclude": ["**/test_*.py"]
}

0 comments on commit 61ac967

Please sign in to comment.