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
21 changes: 21 additions & 0 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Deploy Release
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
environment: Production
env:
TWINE_USERNAME: ${{ secrets.TWINE_USERNAME_PYPI }}
TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD_PYPI }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: "3.12"
- name: Install dependencies
run: python -m pip install --upgrade build twine
- name: Build version
run: python -m build
- name: Deploy to PyPi
run: python -m twine upload dist/*
23 changes: 23 additions & 0 deletions .github/workflows/test-deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Deploy Test Release
on:
push:
branches:
- 'release/**'
jobs:
test-deploy:
runs-on: ubuntu-latest
environment: Staging
env:
TWINE_USERNAME: ${{ secrets.TWINE_USERNAME_TESTPYPI }}
TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD_TESTPYPI }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: "3.12"
- name: Install dependencies
run: python -m pip install --upgrade build twine
- name: Build version
run: python -m build
- name: Deploy to TestPypi
run: python -m twine upload --repository testpypi dist/*
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13' ]
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14' ]
os: [ ubuntu-latest, windows-latest, macos-latest ]
steps:
- uses: actions/checkout@v3
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ podcast_cache = CacheStore(store='podcasts')

### Setting and retrieving values

The main methods are `get()`, `put()`, `has()`, and `forget()`.
The main methods are `get()`, `put()`, `has()`, `forget()`, and `clear()`.

```python
from light_cache import CacheStore
Expand All @@ -91,6 +91,9 @@ cache.put('never-expire-key', 'never-expire-value', expires=None)

# Items that do not expire will remain in cache until the item is removed using `forget()`
cache.forget('never-expire-key')

# `clear()` removes all items from the store at once
cache.clear()
```

### Change location of the cache directory
Expand Down Expand Up @@ -123,12 +126,18 @@ To run tests locally, use:
poetry run pytest
```

#### Formatter
#### Formatter and linter

This project uses [ruff](https://docs.astral.sh/ruff/) for formatting and linting. To run the formatter, use:

```shell
poetry run ruff format .
```

This project uses [black](https://black.readthedocs.io/en/stable/index.html) for formatting. To run the formatter, use:
To run the linter, use:

```shell
poetry run black .
poetry run ruff check .
```

## License
Expand Down
22 changes: 20 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "light-cache"
version = "0.2.1"
version = "1.0.0"
description = "A simple, lightweight caching library for disk-based or object-based caching"
keywords = [ "cache" ]
authors = [
Expand All @@ -25,5 +25,23 @@ pytest = "^8.4.1"


[tool.poetry.group.dev.dependencies]
black = "^25.1.0"
ruff = "^0.15.0"

[tool.ruff]
line-length = 88

[tool.ruff.lint]
select = [
"E",
"W",
"F",
"I",
"UP",
"B",
"RUF",
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

2 changes: 2 additions & 0 deletions src/light_cache/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from .cache_store import CacheStore

__all__ = ["CacheStore"]
36 changes: 24 additions & 12 deletions src/light_cache/cache_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@

class CacheStore:
"""
A flexible caching system that supports both in-memory and persistent file-based caching.
A flexible caching system that supports both in-memory and persistent
file-based caching.

This class provides functionality to cache data with optional expiration times,
persistence to disk, and memory-only operations. It handles JSON serialization
of cached data and provides methods for storing, retrieving, and managing cached items.
of cached data and provides methods for storing, retrieving, and managing
cached items.

Args:
persist_cache (bool): Whether to save cache to disk. Defaults to True.
keep_cache_in_memory (bool): Whether to maintain an in-memory cache. Defaults to True.
keep_cache_in_memory (bool): Whether to maintain an in-memory cache.
Defaults to True.
store (str): Name of the cache store/file. Defaults to "general_cache".
cache_directory (str): Directory to store cache files. Defaults to ".cache".
"""
Expand Down Expand Up @@ -87,7 +90,7 @@ def put(self, key: str, item, expires: int | None = 600):
key (str): The key under which to store the item.
item (Any): The data to cache.
expires (int | None): Time in seconds until the item expires.
None means the item never expires. Defaults to 600 seconds.
None means the item never expires. Defaults to 600 seconds.
"""
cache = self.load_cache()

Expand Down Expand Up @@ -152,13 +155,14 @@ def pull(self, key: str, default=None):

Args:
key (str): The key to look up in the cache.
default: Value to return if the key is not found or expired. Defaults to None.
default: Value to return if the key is not found or expired.
Defaults to None.

Returns:
The cached item if found and not expired, otherwise the default value.
"""
# We could have used get() and forget() here but both use load_cache. If not using memory, would
# have needed to load from the file twice so avoid using those helpers here for performance.
# Avoids calling get() + forget() since both invoke load_cache().
# In disk-only mode that would read the file twice; one pass is faster.
cache = self.load_cache()

if key not in cache:
Expand All @@ -178,6 +182,13 @@ def pull(self, key: str, default=None):
self.save_cache(cache)
return value

def clear(self) -> None:
"""Remove all items from the cache store."""
cache = self.load_cache()
item_count = len(cache)
self.save_cache({})
logger.info(f"Cleared {item_count} items from cache store '{self.store}'")

def save_cache(self, data: dict) -> None:
"""
Save the cache data to memory and/or disk based on configuration.
Expand All @@ -204,7 +215,8 @@ def load_cache(self, load_from_memory: bool = True) -> dict:
Load the cache from memory or disk based on configuration.

Args:
load_from_memory (bool): If keep_cache_in_memory is set to True, should we use the memory when loading cache this time.
load_from_memory (bool): When keep_cache_in_memory is True, controls
whether to read from the in-memory cache or fall back to disk.

Returns:
dict: The loaded cache data.
Expand All @@ -214,7 +226,7 @@ def load_cache(self, load_from_memory: bool = True) -> dict:

filename = self._get_cache_path()
try:
with open(filename, "r") as cache_file:
with open(filename) as cache_file:
cached_data = cache_file.read()
data = JSONSerializer().decode(cached_data)
logger.debug(f"Loaded {len(data)} items from cache file: {filename}")
Expand Down Expand Up @@ -270,7 +282,7 @@ def _is_cache_directory_needed(self) -> bool:

@staticmethod
def _sanitize_store(store: str) -> str:
"""Sanitize the store by removing path traversal components and invalid chars."""
"""Sanitize the store name, removing path traversal and invalid chars."""
# Remove any directory traversal attempt
base_store = os.path.basename(store)

Expand All @@ -285,7 +297,7 @@ def _sanitize_store(store: str) -> str:

@staticmethod
def _sanitize_directory(directory: str) -> str:
"""Sanitize the directory path by resolving to the absolute path and checking traversal."""
"""Sanitize directory path: resolve symlinks and block traversal outside CWD."""
if not directory or directory == ".":
return "."

Expand All @@ -297,7 +309,7 @@ def _sanitize_directory(directory: str) -> str:
cwd = os.path.realpath(os.getcwd())
if not real_path.startswith(cwd):
logger.warning(
f"Attempted directory traversal outside CWD. Defaulting to '.cache'"
"Attempted directory traversal outside CWD. Defaulting to '.cache'"
)
return ".cache"

Expand Down
111 changes: 110 additions & 1 deletion tests/test_cache_store.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from datetime import datetime
import os
import shutil
from datetime import datetime

import pytest

Expand Down Expand Up @@ -288,6 +288,115 @@ def test_pull_without_memory_cache(temp_cache_dir):
assert cache.has("test_key") is False


def test_clear_removes_all_items():
"""Test that clear() removes all items from a memory cache."""
cache = CacheStore(persist_cache=False, keep_cache_in_memory=True)
cache.put("key1", "value1")
cache.put("key2", "value2")
cache.put("key3", "value3")

cache.clear()

assert cache.has("key1") is False
assert cache.has("key2") is False
assert cache.has("key3") is False


def test_clear_returns_none():
"""Test that clear() returns None, matching Python built-in clear() conventions."""
cache = CacheStore(persist_cache=False, keep_cache_in_memory=True)
cache.put("key1", "value1")

result = cache.clear()

assert result is None


def test_clear_on_empty_cache():
"""Test that clear() on an already empty cache does not raise."""
cache = CacheStore(persist_cache=False, keep_cache_in_memory=True)

cache.clear()

assert cache.load_cache() == {}


def test_clear_allows_new_items_after():
"""Test that the cache is still usable after clear()."""
cache = CacheStore(persist_cache=False, keep_cache_in_memory=True)
cache.put("key1", "value1")

cache.clear()
cache.put("key2", "value2")

assert cache.get("key1") is None
assert cache.get("key2") == "value2"


def test_clear_persists_to_disk(temp_cache_dir):
"""Test that clear() removes all items from disk so a new instance starts empty."""
cache = CacheStore(
persist_cache=True,
keep_cache_in_memory=True,
store="test_cache",
cache_directory=temp_cache_dir,
)
cache.put("key1", "value1")
cache.put("key2", "value2")

cache.clear()

new_cache = CacheStore(
persist_cache=True,
keep_cache_in_memory=True,
store="test_cache",
cache_directory=temp_cache_dir,
)
assert new_cache.get("key1") is None
assert new_cache.get("key2") is None
assert new_cache.load_cache() == {}


def test_clear_disk_only_mode(temp_cache_dir):
"""Test that clear() works correctly in disk-only mode."""
cache = CacheStore(
persist_cache=True,
keep_cache_in_memory=False,
store="test_cache",
cache_directory=temp_cache_dir,
)
cache.put("key1", "value1")
cache.put("key2", "value2")

cache.clear()

assert cache.has("key1") is False
assert cache.has("key2") is False


def test_clear_does_not_affect_other_stores(temp_cache_dir):
"""Test that clear() only removes items from its own store."""
cache_a = CacheStore(
persist_cache=True,
keep_cache_in_memory=True,
store="store_a",
cache_directory=temp_cache_dir,
)
cache_b = CacheStore(
persist_cache=True,
keep_cache_in_memory=True,
store="store_b",
cache_directory=temp_cache_dir,
)
cache_a.put("key1", "value1")
cache_b.put("key2", "value2")

cache_a.clear()

assert cache_a.has("key1") is False
assert cache_b.has("key2") is True


def test_is_cache_directory_needed_with_valid_directory():
cache = CacheStore(persist_cache=False, store="test", cache_directory="test_dir")
assert cache._is_cache_directory_needed() is True
Expand Down
Loading