From d2f0dc646a7bdc111e7d3e15e0e1c7a57cb33cfa Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Fri, 29 May 2026 19:45:02 +0200 Subject: [PATCH] make caching opt-in --- .env.example | 5 ++-- README.md | 5 ++-- app/main.py | 40 ++++++++++++++++----------- app/settings.py | 4 +-- tests/test_cache.py | 64 ++++++++++++++++++++++++++++++++------------ tests/test_routes.py | 43 +++++++++++++++-------------- 6 files changed, 103 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index c641bce..fb4da99 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,9 @@ GITEA_BASE_URL=https://gitea.example.com GITEA_USERNAME=octocat GITEA_TOKEN= -CACHE_TTL_SECONDS=3600 -CACHE_DIR=/app/cache +# Leave both unset to disable caching. +# CACHE_TTL_SECONDS=3600 +# CACHE_DIR=/app/cache DEFAULT_THEME=light SERVICE_TITLE=git-activity-merge diff --git a/README.md b/README.md index 9eab364..86726b4 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,12 @@ Optional: - `GITHUB_TOKEN` - `GITEA_TOKEN` -- `CACHE_TTL_SECONDS` (default: `3600`) -- `CACHE_DIR` (default: `./cache` locally, `/app/cache` in container) +- `CACHE_TTL_SECONDS` and `CACHE_DIR` enable caching when both are set - `DEFAULT_THEME` (`light` or `dark`, default: `light`) - `SERVICE_TITLE` (default: `git-activity-merge`) +If you leave `CACHE_TTL_SECONDS` and `CACHE_DIR` undefined, the app skips caching entirely. + ### 3. Run ```bash diff --git a/app/main.py b/app/main.py index 8525f40..a754b9e 100644 --- a/app/main.py +++ b/app/main.py @@ -43,7 +43,9 @@ class ActivityResult: app = FastAPI(title=os.getenv("SERVICE_TITLE", "git-activity-merge")) -def get_cache(settings: Settings = Depends(get_settings)) -> FileCache: +def get_cache(settings: Settings = Depends(get_settings)) -> FileCache | None: + if settings.cache_dir is None or settings.cache_ttl_seconds is None: + return None return FileCache(cache_dir=settings.cache_dir, default_ttl_seconds=settings.cache_ttl_seconds) @@ -88,10 +90,14 @@ def _date_keys(from_date: date, to_date: date) -> list[str]: async def _fetch_with_cache( - cache: FileCache, + cache: FileCache | None, key: str, fetcher, ) -> tuple[dict[str, int], bool]: + if cache is None: + fresh = await fetcher() + return fresh, False + cached = cache.get_json(key, allow_stale=True) if cached and not cached.stale: return {k: int(v) for k, v in cached.value.items()}, False @@ -109,7 +115,7 @@ async def _fetch_with_cache( async def collect_merged_activity( settings: Settings, - cache: FileCache, + cache: FileCache | None, options: QueryOptions, ) -> ActivityResult: from_date, to_date, days_count = compute_date_range(options) @@ -178,7 +184,7 @@ async def health(settings: Settings = Depends(get_settings)) -> dict[str, str]: @app.get("/activity.json") async def activity_json( settings: Settings = Depends(get_settings), - cache: FileCache = Depends(get_cache), + cache: FileCache | None = Depends(get_cache), options: QueryOptions = Depends(get_query_options), ) -> JSONResponse: try: @@ -203,7 +209,7 @@ async def activity_json( @app.get("/activity.svg") async def activity_svg( settings: Settings = Depends(get_settings), - cache: FileCache = Depends(get_cache), + cache: FileCache | None = Depends(get_cache), options: QueryOptions = Depends(get_query_options), ) -> Response: try: @@ -213,9 +219,10 @@ async def activity_svg( raise HTTPException(status_code=502, detail="Failed to fetch activity data") from exc cache_key = _image_cache_key("svg", options, result) - cached = cache.get_json(cache_key) - if cached is not None: - return Response(content=str(cached.value), media_type="image/svg+xml") + if cache is not None: + cached = cache.get_json(cache_key) + if cached is not None: + return Response(content=str(cached.value), media_type="image/svg+xml") daily_totals = _daily_totals(result.merged) total = sum(daily_totals.values()) @@ -227,14 +234,15 @@ async def activity_svg( total_contributions=total, theme=options.theme, ) - cache.set_json(cache_key, svg) + if cache is not None: + cache.set_json(cache_key, svg) return Response(content=svg, media_type="image/svg+xml") @app.get("/activity.png") async def activity_png( settings: Settings = Depends(get_settings), - cache: FileCache = Depends(get_cache), + cache: FileCache | None = Depends(get_cache), options: QueryOptions = Depends(get_query_options), ) -> Response: try: @@ -244,10 +252,11 @@ async def activity_png( raise HTTPException(status_code=502, detail="Failed to fetch activity data") from exc cache_key = _image_cache_key("png", options, result) - cached = cache.get_json(cache_key) - if cached is not None: - png_data = bytes.fromhex(str(cached.value)) - return Response(content=png_data, media_type="image/png") + if cache is not None: + cached = cache.get_json(cache_key) + if cached is not None: + png_data = bytes.fromhex(str(cached.value)) + return Response(content=png_data, media_type="image/png") daily_totals = _daily_totals(result.merged) total = sum(daily_totals.values()) @@ -264,5 +273,6 @@ async def activity_png( except Exception as exc: logger.exception("failed to render png") raise HTTPException(status_code=500, detail="PNG rendering failed") from exc - cache.set_json(cache_key, png_data.hex()) + if cache is not None: + cache.set_json(cache_key, png_data.hex()) return Response(content=png_data, media_type="image/png") diff --git a/app/settings.py b/app/settings.py index d72ef61..9e3a8fa 100644 --- a/app/settings.py +++ b/app/settings.py @@ -14,8 +14,8 @@ class Settings(BaseSettings): gitea_username: str = Field(alias="GITEA_USERNAME") gitea_token: str | None = Field(default=None, alias="GITEA_TOKEN") - cache_ttl_seconds: int = Field(default=3600, alias="CACHE_TTL_SECONDS") - cache_dir: str = Field(default="./cache", alias="CACHE_DIR") + cache_ttl_seconds: int | None = Field(default=None, alias="CACHE_TTL_SECONDS") + cache_dir: str | None = Field(default=None, alias="CACHE_DIR") default_theme: str = Field(default="light", alias="DEFAULT_THEME") service_title: str = Field(default="git-activity-merge", alias="SERVICE_TITLE") diff --git a/tests/test_cache.py b/tests/test_cache.py index 6b3c792..1077c30 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,28 +1,58 @@ import time +import tempfile +from pathlib import Path + from app.cache import FileCache +from app.main import get_cache +from app.settings import Settings -def test_cache_returns_fresh_entry(tmp_path) -> None: - cache = FileCache(cache_dir=str(tmp_path), default_ttl_seconds=10) - cache.set_json("sample", {"x": 1}) +def test_cache_returns_fresh_entry() -> None: + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp_dir: + cache = FileCache(cache_dir=temp_dir, default_ttl_seconds=10) + cache.set_json("sample", {"x": 1}) - entry = cache.get_json("sample") + entry = cache.get_json("sample") - assert entry is not None - assert entry.stale is False - assert entry.value == {"x": 1} + assert entry is not None + assert entry.stale is False + assert entry.value == {"x": 1} -def test_cache_can_serve_stale_when_requested(tmp_path) -> None: - cache = FileCache(cache_dir=str(tmp_path), default_ttl_seconds=0) - cache.set_json("sample", {"x": 1}) - time.sleep(0.01) +def test_cache_can_serve_stale_when_requested() -> None: + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp_dir: + cache = FileCache(cache_dir=temp_dir, default_ttl_seconds=0) + cache.set_json("sample", {"x": 1}) + time.sleep(0.01) - fresh = cache.get_json("sample") - stale = cache.get_json("sample", allow_stale=True) + fresh = cache.get_json("sample") + stale = cache.get_json("sample", allow_stale=True) - assert fresh is None - assert stale is not None - assert stale.stale is True - assert stale.value == {"x": 1} + assert fresh is None + assert stale is not None + assert stale.stale is True + assert stale.value == {"x": 1} + + +def test_cache_is_disabled_when_not_configured() -> None: + settings = Settings( + _env_file=None, + GITHUB_USERNAME="octocat", + GITEA_BASE_URL="https://gitea.example.com", + GITEA_USERNAME="octocat", + ) + + cache = get_cache(settings) + + assert cache is None + + +def test_cache_works_in_workspace_tempdir() -> None: + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp_dir: + cache = FileCache(cache_dir=temp_dir, default_ttl_seconds=10) + cache.set_json("sample", {"x": 1}) + entry = cache.get_json("sample") + + assert entry is not None + assert entry.value == {"x": 1} diff --git a/tests/test_routes.py b/tests/test_routes.py index 4f76c55..5b30066 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,3 +1,5 @@ +import tempfile +from pathlib import Path from datetime import date from fastapi.testclient import TestClient @@ -6,30 +8,31 @@ from app.main import ActivityResult, app from app.settings import get_settings -def test_activity_svg_returns_svg_content_type(monkeypatch, tmp_path) -> None: +def test_activity_svg_returns_svg_content_type(monkeypatch) -> None: monkeypatch.setenv("GITHUB_USERNAME", "octocat") monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com") monkeypatch.setenv("GITEA_USERNAME", "octocat") - monkeypatch.setenv("CACHE_DIR", str(tmp_path)) - get_settings.cache_clear() + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp_dir: + monkeypatch.setenv("CACHE_DIR", temp_dir) + get_settings.cache_clear() - async def fake_collect_merged_activity(settings, cache, options): - return ActivityResult( - merged={ - "2026-01-01": {"github": 1, "gitea": 2, "total": 3}, - "2026-01-02": {"github": 0, "gitea": 0, "total": 0}, - }, - stale=False, - from_date=date(2026, 1, 1), - to_date=date(2026, 1, 2), - days_count=2, - ) + async def fake_collect_merged_activity(settings, cache, options): + return ActivityResult( + merged={ + "2026-01-01": {"github": 1, "gitea": 2, "total": 3}, + "2026-01-02": {"github": 0, "gitea": 0, "total": 0}, + }, + stale=False, + from_date=date(2026, 1, 1), + to_date=date(2026, 1, 2), + days_count=2, + ) - monkeypatch.setattr("app.main.collect_merged_activity", fake_collect_merged_activity) + monkeypatch.setattr("app.main.collect_merged_activity", fake_collect_merged_activity) - client = TestClient(app) - response = client.get("/activity.svg") + client = TestClient(app) + response = client.get("/activity.svg") - assert response.status_code == 200 - assert response.headers["content-type"].startswith("image/svg+xml") - assert "