Skip to content

Commit 410543c

Browse files
shaypal5pre-commit-ci[bot]Copilot
authored
Add an SQL core (#267)
* Add an SQL core * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * black and flake8 * tests for sql core * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * only test sql backend on ubuntu currently * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * more fixes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * more linting nightmare * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * more error fixin * install sql deps only for sql backend tests * no import warning on sql core imports * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * more sql core tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fixes and linting * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test fixes and more copilot instructions * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * more fixes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix pytest arg to run only local tests * fix var name * a couple more sql core tests * addressing copilot comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove evil lines from pyproject.toml * fix test_sqlcore_set_entry_fallback * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * readme note fix * pylint and flake8 be gone * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update README.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent d9dd752 commit 410543c

13 files changed

Lines changed: 815 additions & 23 deletions

File tree

.github/copilot-instructions.md

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,59 @@
1-
# GitHub Copilot Custom Instructions for Cachier
1+
# Copilot Instructions for Cachier
22

3-
- Cachier is a Python package providing persistent, stale-free memoization decorators for Python functions, supporting local (pickle), cross-machine (MongoDB), and in-memory caching backends.
4-
- Always refer to the main decorator as `@cachier`, and note that it can be configured via parameters such as `stale_after`, `backend`, `mongetter`, `cache_dir`, `pickle_reload`, `separate_files`, `wait_for_calc_timeout`, and `allow_none`.
5-
- Arguments to cached functions must be hashable; custom hash functions can be provided via the `hash_func` parameter for unhashable arguments.
3+
Welcome to the Cachier codebase! Please follow these guidelines to ensure code suggestions, reviews, and contributions are robust, maintainable, and compatible with our multi-backend architecture.
4+
5+
## 1. Decorator and API Usage
6+
7+
- The main decorator is `@cachier`. It supports parameters such as `stale_after`, `backend`, `mongetter`, `cache_dir`, `pickle_reload`, `separate_files`, `wait_for_calc_timeout`, `allow_none`, and `hash_func`.
8+
- Arguments to cached functions must be hashable; for unhashable arguments, provide a custom hash function via the `hash_func` parameter.
69
- The default backend is pickle-based, storing cache files in `~/.cachier/` unless otherwise specified. MongoDB and memory backends are also supported.
710
- Cachier is thread-safe and supports per-function cache clearing via the `clear_cache()` method on decorated functions.
8-
- Global configuration is possible via `set_default_params` and `enable_caching`/`disable_caching` functions.
9-
- When reviewing code, ensure new features or bugfixes maintain compatibility with Python 3.9+, preserve thread safety, and follow the numpy docstring conventions for documentation.
10-
- Tests are located in the `tests/` directory and should be run with `pytest`. MongoDB-related tests require either a mocked or live MongoDB instance.
11-
- When discussing or generating code, prefer concise, readable, and well-documented Python code, and follow the established conventions in the codebase and README.
11+
- Global configuration is possible via `set_default_params`, `set_global_params`, and `enable_caching`/`disable_caching`.
12+
13+
## 2. Optional Dependencies and Backends
14+
15+
- Cachier supports multiple backends: `pickle`, `memory`, `mongo`, and `sql`.
16+
- Not all dependencies are required for all backends. Code and tests for optional backends (e.g., MongoDB, SQL/SQLAlchemy) **must gracefully handle missing dependencies** and should not break import or test collection for other backends.
17+
- Only raise errors or warnings for missing dependencies when the relevant backend is actually used (not at import time).
18+
19+
## 3. Testing Matrix and Markers
20+
21+
- Tests are located in the `tests/` directory and should be run with `pytest`.
22+
- Tests are marked with `@pytest.mark.<backend>` (e.g., `@pytest.mark.sql`, `@pytest.mark.mongo`, `@pytest.mark.local`).
23+
- The CI matrix runs different backends on different OSes. Do **not** assume all tests run on all platforms.
24+
- MongoDB-related tests require either a mocked or live MongoDB instance.
25+
- When adding new backends that require external services (e.g., databases), update the CI matrix and use Dockerized services as in the current MongoDB and PostgreSQL setup. Exclude backends from OSes where they are not supported.
26+
27+
## 4. Coverage, Linting, and Typing
28+
29+
- Code must pass `mypy`, `ruff`, and `pytest`.
30+
- Use per-file or per-line ignores for known, justified issues (e.g., SQLAlchemy model base class typing, intentional use of `pickle`).
31+
- All new code must include full type annotations and docstrings matching the style of the existing codebase.
32+
- All docstrings should follow numpy docstring conventions.
33+
34+
## 5. Error Handling and Warnings
35+
36+
- Do **not** emit warnings at import time for missing optional dependencies. Only raise errors or warnings when the relevant backend is actually used.
37+
38+
## 6. Backward Compatibility
39+
40+
- Maintain backward compatibility for public APIs unless a breaking change is explicitly approved.
41+
- Cachier supports Python 3.9+.
42+
43+
## 7. Documentation and Examples
44+
45+
- When adding a new backend or feature, provide:
46+
- Example usage in the README
47+
- At least one test for each public method
48+
- Documentation of any new configuration options
1249
- For documentation, follow numpy docstring conventions and validate changes to `README.rst` with `python setup.py checkdocs`.
50+
51+
## 8. General Style
52+
53+
- Prefer concise, readable, and well-documented Python code.
54+
- Follow the existing code style and conventions for imports, docstrings, and type annotations.
55+
- Prefer explicit, readable code over cleverness.
56+
57+
______________________________________________________________________
58+
59+
Thank you for contributing to Cachier! These guidelines help ensure a robust, maintainable, and user-friendly package for everyone.

.github/workflows/ci-test.yml

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ jobs:
2222
matrix:
2323
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
2424
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
25-
backend: ["local", "db"]
25+
backend: ["local", "mongodb", "postgres"]
2626
exclude:
2727
# ToDo: take if back when the connection become stable
2828
# or resolve using `InMemoryMongoClient`
29-
- { os: "macOS-latest", backend: "db" }
29+
- { os: "macOS-latest", backend: "mongodb" }
30+
- { os: "macOS-latest", backend: "postgres" }
31+
- { os: "windows-latest", backend: "postgres" }
3032
env:
3133
CACHIER_TEST_HOST: "localhost"
3234
CACHIER_TEST_PORT: "27017"
@@ -50,37 +52,63 @@ jobs:
5052
5153
- name: Unit tests (local)
5254
if: matrix.backend == 'local'
53-
run: pytest -m "not mongo" --cov=cachier --cov-report=term --cov-report=xml:cov.xml
55+
run: pytest -m "not mongo and not sql" --cov=cachier --cov-report=term --cov-report=xml:cov.xml
5456

5557
- name: Setup docker (missing on MacOS)
56-
if: runner.os == 'macOS' && matrix.backend == 'db'
58+
if: runner.os == 'macOS' && matrix.backend == 'mongodb'
5759
run: |
5860
brew install docker
5961
colima start
6062
# For testcontainers to find the Colima socket
6163
sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock
6264
# ToDo: find a way to cache docker images
6365
#- name: Cache Container Images
64-
# if: matrix.backend == 'db'
66+
# if: matrix.backend == 'mongodb'
6567
# uses: borda/cache-container-images-action@b32a5e804cb39af3c3d134fc03ab76eac0bfcfa9
6668
# with:
6769
# prefix-key: "mongo-db"
6870
# images: mongo:latest
71+
6972
- name: Start MongoDB in docker
70-
if: matrix.backend == 'db'
73+
if: matrix.backend == 'mongodb'
7174
run: |
7275
# start MongoDB in a container
7376
docker run -d -p ${{ env.CACHIER_TEST_PORT }}:27017 --name mongodb mongo:latest
7477
# wait for MongoDB to start, which is in average 5 seconds
7578
sleep 5
7679
# show running containers
7780
docker ps -a
81+
7882
- name: Unit tests (DB)
79-
if: matrix.backend == 'db'
83+
if: matrix.backend == 'mongodb'
8084
run: pytest -m "mongo" --cov=cachier --cov-report=term --cov-report=xml:cov.xml
8185
- name: Speed eval
8286
run: python tests/speed_eval.py
8387

88+
- name: Start PostgreSQL in docker
89+
if: matrix.backend == 'postgres'
90+
run: |
91+
docker run -d \
92+
-e POSTGRES_USER=testuser \
93+
-e POSTGRES_PASSWORD=testpass \
94+
-e POSTGRES_DB=testdb \
95+
-p 5432:5432 \
96+
--name postgres postgres:15
97+
# wait for PostgreSQL to start
98+
sleep 10
99+
docker ps -a
100+
101+
- name: Install SQL core test dependencies (SQL/Postgres)
102+
if: matrix.backend == 'postgres'
103+
run: |
104+
python -m pip install -e . -r tests/sql_requirements.txt
105+
106+
- name: Unit tests (SQL/Postgres)
107+
if: matrix.backend == 'postgres'
108+
env:
109+
SQLALCHEMY_DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
110+
run: pytest -m sql --cov=cachier --cov-report=term --cov-report=xml:cov.xml
111+
84112
- name: Upload coverage to Codecov
85113
continue-on-error: true
86114
uses: codecov/codecov-action@v5

README.rst

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,64 @@ You can set an in-memory cache by assigning the ``backend`` parameter with ``'me
342342
343343
Note, however, that ``cachier``'s in-memory core is simple, and has no monitoring or cap on cache size, and can thus lead to memory errors on large return values - it is mainly intended to be used with future multi-core functionality. As a rule, Python's built-in ``lru_cache`` is a much better stand-alone solution.
344344

345+
SQLAlchemy (SQL) Core
346+
---------------------
347+
348+
**Note:** The SQL core requires SQLAlchemy to be installed. It is not installed by default with cachier. To use the SQL backend, run::
349+
350+
pip install SQLAlchemy
351+
352+
Cachier supports a generic SQL backend via SQLAlchemy, allowing you to use SQLite, PostgreSQL, MySQL, and other databases.
353+
354+
**Usage Example (SQLite in-memory):**
355+
356+
.. code-block:: python
357+
358+
from cachier import cachier
359+
360+
@cachier(backend="sql", sql_engine="sqlite:///:memory:")
361+
def my_func(x):
362+
return x * 2
363+
364+
**Usage Example (PostgreSQL):**
365+
366+
.. code-block:: python
367+
368+
@cachier(backend="sql", sql_engine="postgresql://user:pass@localhost/dbname")
369+
def my_func(x):
370+
return x * 2
371+
372+
**Usage Example (MySQL):**
373+
374+
.. code-block:: python
375+
376+
@cachier(backend="sql", sql_engine="mysql+pymysql://user:pass@localhost/dbname")
377+
def my_func(x):
378+
return x * 2
379+
380+
**Configuration Options:**
381+
382+
- ``sql_engine``: SQLAlchemy connection string, Engine, or callable returning an Engine.
383+
- All other standard cachier options are supported.
384+
385+
**Table Schema:**
386+
387+
- ``function_id``: Unique identifier for the cached function
388+
- ``key``: Cache key
389+
- ``value``: Pickled result
390+
- ``timestamp``: Datetime of cache entry
391+
- ``stale``: Boolean, is value stale
392+
- ``processing``: Boolean, is value being calculated
393+
- ``completed``: Boolean, is value calculation completed
394+
395+
**Limitations & Notes:**
396+
397+
- Requires SQLAlchemy (install with ``pip install SQLAlchemy``)
398+
- For production, use a persistent database (not ``:memory:``)
399+
- Thread/process safety is handled via transactions and row-level locks
400+
- Value serialization uses ``pickle``. **Warning:** `pickle` can execute arbitrary code during deserialization if the cache database is compromised. Ensure the cache is stored securely and consider using safer serialization methods like `json` if security is a concern.
401+
- For best performance, ensure your DB supports row-level locking
402+
345403

346404
Contributing
347405
============

pyproject.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# === Metadata & Build System ===
2+
13
[build-system]
24
requires = [
35
"setuptools",
@@ -46,6 +48,8 @@ dependencies = [
4648
"watchdog>=2.3.1",
4749
]
4850
urls.Source = "https://github.com/python-cachier/cachier"
51+
# --- setuptools ---
52+
4953
scripts.cachier = "cachier.__main__:cli"
5054

5155
[tool.setuptools]
@@ -63,6 +67,13 @@ include = [
6367
] # package names should match these glob patterns (["*"] by default)
6468
namespaces = false # to disable scanning PEP 420 namespaces (true by default)
6569

70+
# === Linting & Formatting ===
71+
72+
[tool.black]
73+
line-length = 79
74+
75+
# --- ruff ---
76+
6677
[tool.ruff]
6778
target-version = "py38"
6879
line-length = 79
@@ -97,13 +108,16 @@ lint.extend-select = [
97108
lint.ignore = [
98109
"C901",
99110
"E203",
111+
"F824",
112+
"W503",
100113
]
101114
lint.per-file-ignores."src/**/__init__.py" = [
102115
"D104",
103116
]
104117
lint.per-file-ignores."src/cachier/config.py" = [
105118
"D100",
106119
]
120+
lint.per-file-ignores."src/cachier/cores/sql.py" = [ "S301" ]
107121
lint.per-file-ignores."tests/**" = [
108122
"D100",
109123
"D101",
@@ -119,6 +133,7 @@ lint.unfixable = [
119133
"F401",
120134
]
121135

136+
# --- flake8 ---
122137
#[tool.ruff.pydocstyle]
123138
## Use Google-style docstrings.
124139
#convention = "google"
@@ -134,6 +149,10 @@ wrap-summaries = 79
134149
wrap-descriptions = 79
135150
blank = true
136151

152+
# === Testing ===
153+
154+
# --- pytest ---
155+
137156
[tool.pytest.ini_options]
138157
testpaths = [
139158
"cachier",
@@ -154,8 +173,11 @@ markers = [
154173
"mongo: test the MongoDB core",
155174
"memory: test the memory core",
156175
"pickle: test the pickle core",
176+
"sql: test the SQL core",
157177
]
158178

179+
# --- coverage ---
180+
159181
[tool.coverage.run]
160182
branch = true
161183
# dynamic_context = "test_function"

src/cachier/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from ._version import * # noqa: F403
1+
from ._version import __version__
22
from .config import (
33
disable_caching,
44
enable_caching,
@@ -17,4 +17,5 @@
1717
"get_global_params",
1818
"enable_caching",
1919
"disable_caching",
20+
"__version__",
2021
]

src/cachier/_version.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
def _get_git_sha() -> str:
1919
from subprocess import DEVNULL, check_output
2020

21-
out = check_output(["git", "rev-parse", "--short", "HEAD"], stderr=DEVNULL) # noqa: S603, S607
21+
args = ["git", "rev-parse", "--short", "HEAD"]
22+
out = check_output(args, stderr=DEVNULL) # noqa: S603
2223
return out.decode("utf-8").strip()
2324

2425

src/cachier/core.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from concurrent.futures import ThreadPoolExecutor
1515
from datetime import datetime, timedelta
1616
from functools import wraps
17-
from typing import Any, Optional, Union
17+
from typing import Any, Callable, Optional, Union
1818
from warnings import warn
1919

2020
from .config import (
@@ -27,6 +27,7 @@
2727
from .cores.memory import _MemoryCore
2828
from .cores.mongo import _MongoCore
2929
from .cores.pickle import _PickleCore
30+
from .cores.sql import _SQLCore
3031

3132
MAX_WORKERS_ENVAR_NAME = "CACHIER_MAX_WORKERS"
3233
DEFAULT_MAX_WORKERS = 8
@@ -107,6 +108,7 @@ def cachier(
107108
hash_params: Optional[HashFunc] = None,
108109
backend: Optional[Backend] = None,
109110
mongetter: Optional[Mongetter] = None,
111+
sql_engine: Optional[Union[str, Any, Callable[[], Any]]] = None,
110112
stale_after: Optional[timedelta] = None,
111113
next_time: Optional[bool] = None,
112114
cache_dir: Optional[Union[str, os.PathLike]] = None,
@@ -134,13 +136,16 @@ def cachier(
134136
hash_params : callable, optional
135137
backend : str, optional
136138
The name of the backend to use. Valid options currently include
137-
'pickle', 'mongo' and 'memory'. If not provided, defaults to
139+
'pickle', 'mongo', 'memory', and 'sql'. If not provided, defaults to
138140
'pickle' unless the 'mongetter' argument is passed, in which
139141
case the mongo backend is automatically selected.
140142
mongetter : callable, optional
141143
A callable that takes no arguments and returns a pymongo.Collection
142144
object with writing permissions. If unset a local pickle cache is used
143145
instead.
146+
sql_engine : str, Engine, or callable, optional
147+
SQLAlchemy connection string, Engine, or callable returning an Engine.
148+
Used for the SQL backend.
144149
stale_after : datetime.timedelta, optional
145150
The time delta after which a cached result is considered stale. Calls
146151
made after the result goes stale will trigger a recalculation of the
@@ -208,6 +213,12 @@ def cachier(
208213
core = _MemoryCore(
209214
hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout
210215
)
216+
elif backend == "sql":
217+
core = _SQLCore(
218+
hash_func=hash_func,
219+
sql_engine=sql_engine,
220+
wait_for_calc_timeout=wait_for_calc_timeout,
221+
)
211222
else:
212223
raise ValueError("specified an invalid core: %s" % backend)
213224

src/cachier/cores/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Defines the interface of a cachier caching core."""
2+
23
# This file is part of Cachier.
34
# https://github.com/python-cachier/cachier
45

src/cachier/cores/mongo.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ def get_entry_by_key(self, key: str) -> Tuple[str, Optional[CacheEntry]]:
7575
)
7676
if not res:
7777
return key, None
78-
val = pickle.loads(res["value"]) if "value" in res else None # noqa: S301
78+
val = None
79+
if "value" in res:
80+
val = pickle.loads(res["value"]) # noqa: S301
7981
entry = CacheEntry(
8082
value=val,
8183
time=res.get("time", None),

0 commit comments

Comments
 (0)