From aebfd4b240a86d7b37a03ce29a6c7c7a14f3715e Mon Sep 17 00:00:00 2001 From: Frank Corso Date: Sun, 10 May 2026 13:50:36 -0400 Subject: [PATCH 1/6] Add 3.14 to CI/CD test pipeline --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 081f0ca..3efd002 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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 From 1d3ab23f45daa84fb782c4a6a21bd9a27b5faf8e Mon Sep 17 00:00:00 2001 From: Frank Corso Date: Sun, 10 May 2026 14:01:02 -0400 Subject: [PATCH 2/6] Add new clear method --- src/light_cache/cache_store.py | 7 +++ tests/test_cache_store.py | 109 +++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/light_cache/cache_store.py b/src/light_cache/cache_store.py index 7adc1aa..4f76d89 100644 --- a/src/light_cache/cache_store.py +++ b/src/light_cache/cache_store.py @@ -178,6 +178,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. diff --git a/tests/test_cache_store.py b/tests/test_cache_store.py index 566ab28..1d7571e 100644 --- a/tests/test_cache_store.py +++ b/tests/test_cache_store.py @@ -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, consistent with Python built-in clear() methods.""" + 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 items from disk so a new instance sees an empty cache.""" + 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 From f6409bd8a55baf4309b3fd7b12b7fe578b66f243 Mon Sep 17 00:00:00 2001 From: Frank Corso Date: Sun, 10 May 2026 14:12:56 -0400 Subject: [PATCH 3/6] Switch to using ruff for formatting and linting --- README.md | 12 +++++++++--- pyproject.toml | 20 +++++++++++++++++++- src/light_cache/__init__.py | 2 ++ src/light_cache/cache_store.py | 29 +++++++++++++++++------------ tests/test_cache_store.py | 6 +++--- 5 files changed, 50 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4f081ed..bbaf8b6 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,18 @@ To run tests locally, use: poetry run pytest ``` -#### Formatter +#### Formatter and linter -This project uses [black](https://black.readthedocs.io/en/stable/index.html) for formatting. To run the formatter, use: +This project uses [ruff](https://docs.astral.sh/ruff/) for formatting and linting. To run the formatter, use: ```shell -poetry run black . +poetry run ruff format . +``` + +To run the linter, use: + +```shell +poetry run ruff check . ``` ## License diff --git a/pyproject.toml b/pyproject.toml index 2b209f1..5f8e25c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,5 +25,23 @@ pytest = "^8.4.1" [tool.poetry.group.dev.dependencies] -black = "^25.1.0" +ruff = "^0.11.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" diff --git a/src/light_cache/__init__.py b/src/light_cache/__init__.py index 994f64c..d1af411 100644 --- a/src/light_cache/__init__.py +++ b/src/light_cache/__init__.py @@ -1 +1,3 @@ from .cache_store import CacheStore + +__all__ = ["CacheStore"] diff --git a/src/light_cache/cache_store.py b/src/light_cache/cache_store.py index 4f76d89..c219586 100644 --- a/src/light_cache/cache_store.py +++ b/src/light_cache/cache_store.py @@ -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". """ @@ -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() @@ -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: @@ -211,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. @@ -221,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}") @@ -277,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) @@ -292,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 "." @@ -304,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" diff --git a/tests/test_cache_store.py b/tests/test_cache_store.py index 1d7571e..e2dfdf0 100644 --- a/tests/test_cache_store.py +++ b/tests/test_cache_store.py @@ -1,6 +1,6 @@ -from datetime import datetime import os import shutil +from datetime import datetime import pytest @@ -303,7 +303,7 @@ def test_clear_removes_all_items(): def test_clear_returns_none(): - """Test that clear() returns None, consistent with Python built-in clear() methods.""" + """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") @@ -334,7 +334,7 @@ def test_clear_allows_new_items_after(): def test_clear_persists_to_disk(temp_cache_dir): - """Test that clear() removes items from disk so a new instance sees an empty cache.""" + """Test that clear() removes all items from disk so a new instance starts empty.""" cache = CacheStore( persist_cache=True, keep_cache_in_memory=True, From 6de22f41b46e3841fcb56c479db8c9009075c4d6 Mon Sep 17 00:00:00 2001 From: Frank Corso Date: Sun, 10 May 2026 14:21:41 -0400 Subject: [PATCH 4/6] Add GitHub action for deploying test release to Test PyPi --- .github/workflows/test-deploy.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/test-deploy.yaml diff --git a/.github/workflows/test-deploy.yaml b/.github/workflows/test-deploy.yaml new file mode 100644 index 0000000..67c4915 --- /dev/null +++ b/.github/workflows/test-deploy.yaml @@ -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/* From 2acb8ae65c1d9344d37e42b8db47f6eed0febd20 Mon Sep 17 00:00:00 2001 From: Frank Corso Date: Sun, 10 May 2026 14:26:52 -0400 Subject: [PATCH 5/6] Add GitHub action for deploying release to PyPi --- .github/workflows/deploy.yaml | 21 +++++++++++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/deploy.yaml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..051d8f1 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -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/* diff --git a/pyproject.toml b/pyproject.toml index 5f8e25c..de627d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ @@ -25,7 +25,7 @@ pytest = "^8.4.1" [tool.poetry.group.dev.dependencies] -ruff = "^0.11.0" +ruff = "^0.15.0" [tool.ruff] line-length = 88 From 8bab31bb43a64702672b06d5947848a0feab9592 Mon Sep 17 00:00:00 2001 From: Frank Corso Date: Sun, 10 May 2026 14:33:49 -0400 Subject: [PATCH 6/6] Add note about new method to readme --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bbaf8b6..3e0ff96 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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