Skip to content
Closed

Dev #232

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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## v2.2.0 (2025-12-12)

### Feat

- **cache**: add cache utilities and tests

## v2.1.0 (2025-12-01)

### Feat
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "funcnodes-core"

version = "2.1.0"
version = "2.2.0"

description = "core package for funcnodes"
authors = [{name = "Julian Kimmig", email = "julian.kimmig@linkdlab.de"}]
Expand Down
75 changes: 75 additions & 0 deletions src/funcnodes_core/utils/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from __future__ import annotations

import json
import os
import tempfile
import shutil
from pathlib import Path
from typing import Any, Optional

from .. import config as fnconfig
from .files import write_json_secure


def get_cache_dir(subdir: str = "cache") -> Path:
"""
Return (and ensure) a cache directory under the funcnodes config dir.

Args:
subdir: Subdirectory name under the config dir.
"""
cache_dir = fnconfig.get_config_dir() / subdir
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir


def get_cache_path(filename: str, subdir: str = "cache") -> Path:
"""Return full path for a cache file within the cache dir."""
return get_cache_dir(subdir) / filename


def get_cache_meta_path_for(cache_path: Path) -> Path:
"""Return a sidecar meta file path next to a cache file."""
return cache_path.with_suffix(cache_path.suffix + ".meta.json")


def get_cache_meta_for(cache_path: Path) -> Optional[dict[str, Any]]:
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type annotation is Optional[dict[str, Any]], but the function can return any type that was stored via set_cache_meta_for. As shown in the test, a string "hello" can be stored and retrieved. The return type should be Optional[Any] to accurately reflect the actual behavior.

Suggested change
def get_cache_meta_for(cache_path: Path) -> Optional[dict[str, Any]]:
def get_cache_meta_for(cache_path: Path) -> Optional[Any]:

Copilot uses AI. Check for mistakes.
"""Read JSON metadata for a given cache file, if present."""
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring is missing documentation for the cache_path parameter. According to best practices, all function parameters should be documented, especially for public API functions.

Suggested change
"""Read JSON metadata for a given cache file, if present."""
"""
Read JSON metadata for a given cache file, if present.
Args:
cache_path: Path to the cache file whose metadata should be read.
"""

Copilot uses AI. Check for mistakes.
meta_path = get_cache_meta_path_for(cache_path)
if not meta_path.exists():
return None
try:
return json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
return None


def set_cache_meta_for(cache_path: Path, meta: dict[str, Any]):
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type annotation for the meta parameter is dict[str, Any], but the function actually accepts any type as demonstrated in the test where a string "hello" is passed. The type annotation should be Any to match the actual behavior, or the function should validate that only dict types are accepted.

Suggested change
def set_cache_meta_for(cache_path: Path, meta: dict[str, Any]):
def set_cache_meta_for(cache_path: Path, meta: Any):

Copilot uses AI. Check for mistakes.
"""Write JSON metadata for a given cache file (atomic)."""
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring is missing documentation for the meta parameter. According to best practices, all function parameters should be documented, especially for public API functions.

Suggested change
"""Write JSON metadata for a given cache file (atomic)."""
"""
Write JSON metadata for a given cache file (atomic).
Args:
cache_path: Path to the cache file.
meta: Metadata dictionary to write as JSON.
"""

Copilot uses AI. Check for mistakes.
meta_path = get_cache_meta_path_for(cache_path)
write_json_secure(meta, meta_path, indent=2)


def write_cache_text(cache_path: Path, text: str, encoding: str = "utf-8"):
"""
Write text to cache_path using a temp file + atomic replace.

This avoids partially-written cache files if the process crashes mid-write.
Comment on lines +55 to +57
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring is missing documentation for parameters (cache_path, text, encoding) and lacks a description of the return value or behavior. According to best practices, all function parameters should be documented, especially for public API functions.

Suggested change
Write text to cache_path using a temp file + atomic replace.
This avoids partially-written cache files if the process crashes mid-write.
Write text to a cache file atomically.
This function writes the given text to the specified cache_path using a temporary file and atomic replace,
which avoids partially-written cache files if the process crashes mid-write.
Args:
cache_path (Path): The path to the cache file to write.
text (str): The text content to write to the cache file.
encoding (str, optional): The encoding to use when writing the file. Defaults to "utf-8".
Returns:
None

Copilot uses AI. Check for mistakes.
"""
cache_path = Path(cache_path)
cache_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
"w+", dir=cache_path.parent, delete=False, encoding=encoding
) as temp_file:
temp_file.write(text)
temp_file.flush()
os.fsync(temp_file.fileno())
temp_path = temp_file.name
os.replace(temp_path, str(cache_path))


def clear_cache():
"""Clear all cache files."""
cache_dir = get_cache_dir()
shutil.rmtree(cache_dir, ignore_errors=True)
cache_dir.mkdir(parents=True, exist_ok=True)
78 changes: 78 additions & 0 deletions tests/test_cache_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import funcnodes_core as fn
from pytest_funcnodes import funcnodes_test


@funcnodes_test
def test_get_cache_dir_creates_under_config_dir():
from funcnodes_core.utils.cache import get_cache_dir

cache_dir = get_cache_dir()
assert cache_dir.exists()
assert cache_dir.is_dir()
assert fn.config.get_config_dir() in cache_dir.parents


@funcnodes_test
def test_cache_meta_roundtrip():
from funcnodes_core.utils.cache import (
get_cache_path,
get_cache_meta_for,
set_cache_meta_for,
)

cache_path = get_cache_path("example.txt")
meta = {"foo": "bar", "num": 1}
set_cache_meta_for(cache_path, meta)

loaded = get_cache_meta_for(cache_path)
assert loaded == meta


@funcnodes_test
def test_write_cache_text_writes_file():
from funcnodes_core.utils.cache import get_cache_path, write_cache_text

cache_path = get_cache_path("example.txt")
write_cache_text(cache_path, "hello")

assert cache_path.exists()
assert cache_path.read_text(encoding="utf-8") == "hello"


@funcnodes_test
def test_clear_cache_clears_cache():
from funcnodes_core.utils.cache import clear_cache, get_cache_dir, write_cache_text

# check that it is empty
assert len(list(get_cache_dir().glob("*"))) == 0
# write a file to the cache
write_cache_text(get_cache_dir() / "test.txt", "hello")
assert (get_cache_dir() / "test.txt").exists()
# check that it is not empty
assert len(list(get_cache_dir().glob("*"))) == 1
# clear the cache
clear_cache()

# check that it is empty
assert get_cache_dir().exists()
assert len(list(get_cache_dir().glob("*"))) == 0


@funcnodes_test
def test_chache_meta_exception_handling():
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function name contains a typo: "chache" should be spelled "cache".

Suggested change
def test_chache_meta_exception_handling():
def test_cache_meta_exception_handling():

Copilot uses AI. Check for mistakes.
from funcnodes_core.utils.cache import (
get_cache_path,
get_cache_meta_for,
set_cache_meta_for,
)

cache_path = get_cache_path("example.txt")
set_cache_meta_for(cache_path, "hello")

assert get_cache_meta_for(cache_path) == "hello"

# write a invalid meta file
(cache_path.with_suffix(cache_path.suffix + ".meta.json")).write_text("invalid")
assert get_cache_meta_for(cache_path) is None

assert get_cache_meta_for(cache_path.with_suffix(cache_path.suffix + ".md")) is None
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.