Skip to content

Commit 8983f4e

Browse files
committed
wip
1 parent 47884e8 commit 8983f4e

File tree

10 files changed

+714
-190
lines changed

10 files changed

+714
-190
lines changed
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# ruff: noqa: F403
2-
from .generic import *
2+
from .file import *
3+
from .types import *
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import hashlib
2+
import logging
3+
import typing as t
4+
from pathlib import Path
5+
6+
import arrow
7+
from pydantic import BaseModel
8+
9+
from .types import CacheBackend, CacheInvalidError, CacheMetadata, CacheOptions
10+
11+
T = t.TypeVar("T", bound=BaseModel)
12+
K = t.TypeVar("K", bound=BaseModel)
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class FileCacheBackend(CacheBackend):
18+
"""A generic cache for pydantic models"""
19+
20+
def __init__(self, cache_dir: str, default_options: CacheOptions):
21+
self.cache_dir = cache_dir
22+
self.default_options = default_options
23+
24+
def store_object(
25+
self, key: str, value: BaseModel, override_options: CacheOptions | None = None
26+
) -> None:
27+
"""Store a single object in the cache
28+
29+
The file cache stores everything as a jsonl file. The first object in
30+
the file is the metadata, which contains the creation time and
31+
expiration time of the cache entry.
32+
"""
33+
34+
# Ensure the cache directory exists
35+
self._ensure_cache_dir()
36+
# Create a file path based on the key
37+
file_path = self._cache_key_path(key)
38+
39+
metadata = CacheMetadata(
40+
created_at=arrow.now().isoformat(),
41+
valid_until=(
42+
arrow.now().shift(seconds=self.default_options.ttl).isoformat()
43+
if self.default_options.ttl > 0
44+
else None
45+
),
46+
)
47+
48+
# Write the value to the file
49+
with open(file_path, "w") as f:
50+
f.write(metadata.model_dump_json() + "\n")
51+
f.write(value.model_dump_json())
52+
53+
def retrieve_object(
54+
self,
55+
key: str,
56+
model_type: type[T],
57+
override_options: CacheOptions | None = None,
58+
) -> T:
59+
"""Retrieve a single object from the cache"""
60+
self._ensure_cache_dir()
61+
file_path = self._cache_key_path(key)
62+
63+
if not file_path.exists():
64+
logger.debug(
65+
f"Cache file not found: {file_path}", extra={"file_path": file_path}
66+
)
67+
raise CacheInvalidError(f"Cache file not found: {file_path}")
68+
69+
with open(file_path, "r") as f:
70+
# Read the metadata and check if it is valid
71+
metadata = CacheMetadata.model_validate_json(f.readline().strip())
72+
73+
if not metadata.is_valid(override_options):
74+
logger.debug(
75+
f"Cache entry is invalid: {metadata}", extra={"metadata": metadata}
76+
)
77+
raise CacheInvalidError(f"Cache entry is invalid: {metadata}")
78+
79+
return model_type.model_validate_json(f.read())
80+
81+
def _cache_dir_path(self):
82+
"""Get the cache directory path"""
83+
return Path(self.cache_dir)
84+
85+
def _ensure_cache_dir(self):
86+
"""Ensure the cache directory exists"""
87+
self._cache_dir_path().mkdir(parents=True, exist_ok=True)
88+
89+
def _cache_key(self, key: str) -> str:
90+
"""Generate a cache key from the pydantic model"""
91+
key_str = hashlib.sha256(key.encode()).hexdigest()
92+
return f"{key_str}.json"
93+
94+
def _cache_key_path(self, key: str) -> Path:
95+
"""Get the cache file path for a given key"""
96+
return self._cache_dir_path() / self._cache_key(key)

lib/oso-core/oso_core/cache/generic.py

Lines changed: 0 additions & 144 deletions
This file was deleted.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import tempfile
2+
3+
import pytest
4+
from pydantic import BaseModel
5+
6+
from .file import FileCacheBackend
7+
from .types import CacheOptions
8+
9+
10+
class FakeModel(BaseModel):
11+
"""A simple Pydantic model for testing purposes."""
12+
13+
name: str
14+
value: int
15+
16+
17+
class FakeNestedModel(BaseModel):
18+
"""A nested Pydantic model for testing purposes."""
19+
20+
name: str
21+
nested: FakeModel
22+
23+
24+
@pytest.fixture
25+
def temp_dir():
26+
"""Create a temporary directory for file system tests."""
27+
with tempfile.TemporaryDirectory() as tmpdirname:
28+
yield tmpdirname
29+
30+
31+
def test_write_file_to_temp_dir(temp_dir):
32+
"""Test writing a file to the temporary directory."""
33+
cache = FileCacheBackend(cache_dir=temp_dir, default_options=CacheOptions(ttl=60))
34+
35+
test0 = FakeModel(name="test", value=42)
36+
37+
cache.store_object(key="test_key0", value=test0)
38+
stored_test0 = cache.retrieve_object(key="test_key0", model_type=FakeModel)
39+
40+
assert test0 == stored_test0, "Stored and retrieved objects should match"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from .types import CacheMetadata
2+
3+
4+
def test_metadata_is_valid():
5+
"""Test that metadata is valid when created and not expired."""
6+
metadata = CacheMetadata(
7+
created_at="2024-01-01T00:00:00Z", valid_until="2024-01-01T00:01:00Z"
8+
)
9+
assert metadata.is_valid() is True
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Caching utilities for pydantic models
2+
3+
Types used to describe a set of caching utilities that can be used with any
4+
pydantic model. This allows us to cache many python objects.
5+
"""
6+
7+
import abc
8+
import typing as t
9+
10+
import arrow
11+
from pydantic import BaseModel
12+
13+
T = t.TypeVar("T", bound=BaseModel)
14+
K = t.TypeVar("K", bound=BaseModel)
15+
16+
17+
class CacheOptions(BaseModel):
18+
ttl: int = 0 # Time to live for the cache in seconds. 0 means no expiration.
19+
20+
21+
class NotFoundError(Exception):
22+
pass
23+
24+
25+
class CacheInvalidError(Exception):
26+
pass
27+
28+
29+
class CacheMetadata(BaseModel):
30+
"""Metadata for the cache entry
31+
32+
This metadata is used to store information about the cache entry such as
33+
when it was created, when it expires, etc.
34+
35+
Attributes:
36+
created_at: The time when the cache entry was created in ISO format.
37+
valid_until: The time when the cache entry expires in ISO format. If
38+
None, the entry only expires if passed in options ttl is
39+
exceeded. If it is set, it is always checked against the
40+
current time.
41+
"""
42+
43+
created_at: str # ISO format timestamp
44+
valid_until: str | None = None # ISO format timestamp
45+
46+
def is_valid(self, options: CacheOptions | None = None) -> bool:
47+
"""Check if the cache entry is valid based on the ttl"""
48+
created_at = arrow.get(self.created_at)
49+
now = arrow.now()
50+
age = created_at - now
51+
52+
if self.valid_until:
53+
valid_until = arrow.get(self.valid_until)
54+
if valid_until < now:
55+
return False
56+
57+
if options:
58+
if options.ttl == 0:
59+
return True
60+
61+
if age.total_seconds() > options.ttl:
62+
return False
63+
64+
return False # Placeholder for actual expiration logic
65+
66+
67+
class CacheBackend(abc.ABC):
68+
"""A generic cache backend interface
69+
70+
This is a generic interface for a cache backend that can be used to store
71+
and retrieve pydantic models. The actual implementation of the cache backend
72+
should inherit from this class and implement the methods.
73+
"""
74+
75+
@abc.abstractmethod
76+
def store_object(
77+
self, key: str, value: BaseModel, override_options: CacheOptions | None = None
78+
) -> None:
79+
"""Store a single object in the cache"""
80+
...
81+
82+
@abc.abstractmethod
83+
def retrieve_object(
84+
self,
85+
key: str,
86+
model_type: type[T],
87+
override_options: CacheOptions | None = None,
88+
) -> T:
89+
"""Retrieve a single object from the cache"""
90+
...

lib/oso-core/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ readme = "README.md"
66
requires-python = ">=3.10"
77
authors = [{ name = "OSO Team", email = "[email protected]" }]
88
dependencies = [
9+
"arrow<2.0.0",
10+
"orjson>=3.10.18",
911
"sqlmesh[trino]<1.0.0,>=0.141.1",
1012
"structlog>=25.4.0",
1113
]

0 commit comments

Comments
 (0)