Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/core/garf_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@
'ApiReportFetcher',
]

__version__ = '0.4.3'
__version__ = '0.5.0'
128 changes: 128 additions & 0 deletions libs/core/garf_core/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Stores and loads reports from a cache instead of calling API."""

from __future__ import annotations

import datetime
import hashlib
import json
import logging
import os
import pathlib
from typing import Final

from garf_core import exceptions, query_editor, report

logger = logging.getLogger(__name__)


class GarfCacheFileNotFoundError(exceptions.GarfError):
"""Exception for not found cached report."""


DEFAULT_CACHE_LOCATION: Final[str] = os.getenv(
'GARF_CACHE_LOCATION', str(pathlib.Path.home() / '.garf/cache/')
)


class GarfCache:
"""Stores and loads reports from a cache instead of calling API.

Attribute:
location: Folder where cached results are stored.
"""

def __init__(
self,
location: str | None = None,
ttl_seconds: int = 3600,
) -> None:
"""Stores and loads reports from a cache instead of calling API.

Args:
location: Folder where cached results are stored.
ttl_seconds: Maximum lifespan of cached objects.
"""
self.location = pathlib.Path(location or DEFAULT_CACHE_LOCATION)
self.ttl_seconds = ttl_seconds

@property
def max_cache_timestamp(self) -> float:
return (
datetime.datetime.now() - datetime.timedelta(seconds=self.ttl_seconds)
).timestamp()

def load(
self, query: query_editor.BaseQueryElements, args=None, kwargs=None
) -> report.GarfReport:
"""Loads report from cache based on query definition.

Args:
query: Query elements.
args: Query parameters.
kwargs: Optional keyword arguments.

Returns:
Cached report.

Raises:
GarfCacheFileNotFoundError: If cached report not found
"""
args_hash = args.hash if args else ''
kwargs_hash = (
hashlib.md5(json.dumps(kwargs).encode('utf-8')).hexdigest()
if kwargs
else ''
)
hash_identifier = f'{query.hash}:{args_hash}:{kwargs_hash}'
cached_path = self.location / f'{hash_identifier}.json'
if (
cached_path.exists()
and cached_path.stat().st_ctime > self.max_cache_timestamp
):
with open(cached_path, 'r', encoding='utf-8') as f:
data = json.load(f)
logger.debug('Report is loaded from cache: %s', str(cached_path))
return report.GarfReport.from_json(data)
raise GarfCacheFileNotFoundError

def save(
self,
fetched_report: report.GarfReport,
query: query_editor.BaseQueryElements,
args=None,
kwargs=None,
) -> None:
"""Saves report to cache based on query definition.

Args:
fetched_report: Report to save.
query: Query elements.
args: Query parameters.
kwargs: Optional keyword arguments.
"""
self.location.mkdir(parents=True, exist_ok=True)
args_hash = args.hash if args else ''
kwargs_hash = (
hashlib.md5(json.dumps(kwargs).encode('utf-8')).hexdigest()
if kwargs
else ''
)
hash_identifier = f'{query.hash}:{args_hash}:{kwargs_hash}'
cached_path = self.location / f'{hash_identifier}.json'
logger.debug('Report is saved to cache: %s', str(cached_path))
with open(cached_path, 'w', encoding='utf-8') as f:
json.dump(fetched_report.to_json(), f)
52 changes: 33 additions & 19 deletions libs/core/garf_core/query_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@

from __future__ import annotations

import dataclasses
import datetime
import hashlib
import json
import logging
import re
from typing import Generator, Union
Expand All @@ -39,6 +40,11 @@ class GarfQueryParameters(pydantic.BaseModel):
macro: QueryParameters = pydantic.Field(default_factory=dict)
template: QueryParameters = pydantic.Field(default_factory=dict)

@property
def hash(self) -> str:
hash_fields = self.model_dump(exclude_none=True)
return hashlib.md5(json.dumps(hash_fields).encode('utf-8')).hexdigest()


class GarfMacroError(query_parser.GarfQueryError):
"""Specifies incorrect macro in Garf query."""
Expand All @@ -52,33 +58,32 @@ class GarfBuiltInQueryError(query_parser.GarfQueryError):
"""Specifies non-existing builtin query."""


@dataclasses.dataclass
class BaseQueryElements:
class BaseQueryElements(pydantic.BaseModel):
"""Contains raw query and parsed elements.

Attributes:
title: Title of the query that needs to be parsed.
text: Text of the query that needs to be parsed.
resource_name: Name of Google Ads API reporting resource.
fields: Ads API fields that need to be fetched.
column_names: Friendly names for fields which are used when saving data
column_names: Friendly names for fields which are used when saving data
customizers: Attributes of fields that need to be be extracted.
virtual_columns: Attributes of fields that need to be be calculated.
is_builtin_query: Whether query is built-in.
title: Title of the query that needs to be parsed.
text: Text of the query that needs to be parsed.
resource_name: Name of Google Ads API reporting resource.
fields: Ads API fields that need to be fetched.
column_names: Friendly names for fields which are used when saving data
column_names: Friendly names for fields which are used when saving data
customizers: Attributes of fields that need to be be extracted.
virtual_columns: Attributes of fields that need to be be calculated.
is_builtin_query: Whether query is built-in.
"""

title: str
title: str | None
text: str
resource_name: str | None = None
fields: list[str] = dataclasses.field(default_factory=list)
filters: list[str] = dataclasses.field(default_factory=list)
sorts: list[str] = dataclasses.field(default_factory=list)
column_names: list[str] = dataclasses.field(default_factory=list)
customizers: dict[str, dict[str, str]] = dataclasses.field(
fields: list[str] = pydantic.Field(default_factory=list)
filters: list[str] = pydantic.Field(default_factory=list)
sorts: list[str] = pydantic.Field(default_factory=list)
column_names: list[str] = pydantic.Field(default_factory=list)
customizers: dict[str, query_parser.Customizer] = pydantic.Field(
default_factory=dict
)
virtual_columns: dict[str, query_parser.VirtualColumn] = dataclasses.field(
virtual_columns: dict[str, query_parser.VirtualColumn] = pydantic.Field(
default_factory=dict
)
is_builtin_query: bool = False
Expand All @@ -87,12 +92,16 @@ def __eq__(self, other: BaseQueryElements) -> bool: # noqa: D105
return (
self.column_names,
self.fields,
self.filters,
self.sorts,
self.resource_name,
self.customizers,
self.virtual_columns,
) == (
other.column_names,
other.fields,
other.filters,
other.sorts,
other.resource_name,
other.customizers,
other.virtual_columns,
Expand All @@ -103,6 +112,11 @@ def request(self) -> str:
"""API request."""
return ','.join(self.fields)

@property
def hash(self) -> str:
hash_fields = self.model_dump(exclude_none=True, exclude={'title', 'text'})
return hashlib.md5(json.dumps(hash_fields).encode('utf-8')).hexdigest()


class CommonParametersMixin:
"""Helper mixin to inject set of common parameters to all queries."""
Expand Down
25 changes: 24 additions & 1 deletion libs/core/garf_core/report_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@

import asyncio
import logging
import pathlib
from typing import Callable

from opentelemetry import trace

from garf_core import (
api_clients,
cache,
exceptions,
parsers,
query_editor,
Expand Down Expand Up @@ -63,6 +65,8 @@ class ApiReportFetcher:
query_specification_builder: Class to perform query parsing.
builtin_queries:
Mapping between query name and function for generating GarfReport.
enable_cache: Whether to load / save report from / to cache.
cache: Cache object.
"""

def __init__(
Expand All @@ -74,6 +78,9 @@ def __init__(
),
builtin_queries: dict[str, Callable[[ApiReportFetcher], report.GarfReport]]
| None = None,
enable_cache: bool = False,
cache_path: str | pathlib.Path | None = None,
cache_ttl_seconds: int = 3600,
**kwargs: str,
) -> None:
"""Instantiates ApiReportFetcher based on provided api client.
Expand All @@ -84,11 +91,16 @@ def __init__(
query_specification_builder: Class to perform query parsing.
builtin_queries:
Mapping between query name and function for generating GarfReport.
enable_cache: Whether to load / save report from / to cache.
cache_path: Optional path to cache folder.
cache_ttl_seconds: Maximum lifespan of cached reports.
"""
self.api_client = api_client
self.parser = parser
self.query_specification_builder = query_specification_builder
self.query_args = kwargs
self.enable_cache = enable_cache
self.cache = cache.GarfCache(cache_path, cache_ttl_seconds)
self.builtin_queries = builtin_queries or {}

def add_builtin_queries(
Expand Down Expand Up @@ -156,13 +168,24 @@ def fetch(
)
return builtin_report(self, **kwargs)

if self.enable_cache:
try:
cached_report = self.cache.load(query, args, kwargs)
logger.warning('Cached version of report is loaded')
span.set_attribute('is_cached_report', True)
return cached_report
except cache.GarfCacheFileNotFoundError:
logger.debug('Cached version not found, generating')
response = self.api_client.call_api(query, **kwargs)
if not response:
return report.GarfReport(query_specification=query)

parsed_response = self.parser(query).parse_response(response)
return report.GarfReport(
fetched_report = report.GarfReport(
results=parsed_response,
column_names=query.column_names,
query_specification=query,
)
if self.enable_cache:
self.cache.save(fetched_report, query, args, kwargs)
return fetched_report
42 changes: 42 additions & 0 deletions libs/core/tests/unit/test_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import pytest
from garf_core import cache, query_editor, report


class TestGarfCache:
@pytest.fixture()
def test_load_returns_report_from_cache(self, tmp_path):
test_cache = cache.GarfCache(location=str(tmp_path))
test_report = report.GarfReport(results=[[1]], column_names=['test'])
query = query_editor.QuerySpecification(
text='SELECT test FROM test'
).generate()

test_cache.save(test_report, query)
loaded_report = cache.load(query)

assert loaded_report == test_cache

def test_load_raises_error_on_outdated_cache(self, tmp_path):
test_cache = cache.GarfCache(location=str(tmp_path), ttl_seconds=0)
test_report = report.GarfReport(results=[[1]], column_names=['test'])
query = query_editor.QuerySpecification(
text='SELECT test FROM test'
).generate()

test_cache.save(test_report, query)
with pytest.raises(cache.GarfCacheFileNotFoundError):
test_cache.load(query)
Loading