From ba245545c76e5b55f1f8d6b83f2485a25e573675 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Wed, 18 Feb 2026 16:49:22 +0200 Subject: [PATCH 1/6] Also test on Python 3.14 --- .github/workflows/ci-test.yml | 2 +- README.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 2ee494c5..79b7c0c0 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -23,7 +23,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] os: ["ubuntu-latest", "macOS-latest", "windows-latest"] backend: ["local", "mongodb", "postgres", "redis"] exclude: diff --git a/README.rst b/README.rst index 55e38286..976042b7 100644 --- a/README.rst +++ b/README.rst @@ -47,7 +47,7 @@ Current features ---------------- * Pure Python. -* Compatible with Python 3.9+ (Python 2.7 was discontinued in version 1.2.8). +* Compatible with Python 3.10+ (Python 2.7 was discontinued in version 1.2.8). * Supported and `tested on Linux, OS X and Windows `_. * A simple interface. * Defining "shelf life" for cached values. From c02f12b5dd2ac1acd3c046390df2e1aee2b3181d Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Wed, 18 Feb 2026 19:01:54 +0200 Subject: [PATCH 2/6] chore: drop Python 3.9 from CI and package metadata --- .github/copilot-instructions.md | 2 +- .github/workflows/ci-test.yml | 2 +- AGENTS.md | 8 ++++---- pyproject.toml | 5 ++--- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0cdf5c88..e758b3f9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,7 +38,7 @@ Welcome to the Cachier codebase! Please follow these guidelines to ensure code s ## 6. Backward Compatibility - Maintain backward compatibility for public APIs unless a breaking change is explicitly approved. -- Cachier supports Python 3.9+. +- Cachier supports Python 3.10+. ## 7. Documentation and Examples diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 79b7c0c0..b4ebb7b5 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -23,7 +23,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] os: ["ubuntu-latest", "macOS-latest", "windows-latest"] backend: ["local", "mongodb", "postgres", "redis"] exclude: diff --git a/AGENTS.md b/AGENTS.md index fb86325b..2e2e460f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ **Cachier** is a Python library providing persistent, stale-free, local and cross-machine caching for Python functions via a decorator API. It supports multiple backends (pickle, memory, MongoDB, SQL, Redis), is thread-safe, and is designed for extensibility and robust cross-platform support. - **Repository:** [python-cachier/cachier](https://github.com/python-cachier/cachier) -- **Primary Language:** Python 3.9+ +- **Primary Language:** Python 3.10+ - **Key Dependencies:** `portalocker`, `watchdog` (optional: `pymongo`, `sqlalchemy`, `redis`) - **Test Framework:** `pytest` with backend-specific markers - **Linting:** `ruff` (replaces black/flake8) @@ -98,7 +98,7 @@ ______________________________________________________________________ ### 1. **Code Style & Quality** -- **Python 3.9+** only. +- **Python 3.10+** only. - **Type annotations** required for all new code. - **Docstrings:** Use numpy style, multi-line, no single-line docstrings. - **Lint:** Run `ruff` before PRs. Use per-line/file ignores only for justified cases. @@ -145,7 +145,7 @@ ______________________________________________________________________ ### 7. **Backward Compatibility** - **Public API must remain backward compatible** unless breaking change is approved. -- **Support for Python 3.9+ only.** +- **Support for Python 3.10+ only.** ### 8. **Global Configuration & Compatibility** @@ -565,7 +565,7 @@ ______________________________________________________________________ - **When adding new features/backends, update all relevant docs, tests, CI, and requirements files.** - **If a test fails due to missing optional dependency, skip gracefully.** - **Never emit warnings/errors for missing optional deps at import time.** -- **All code must be Python 3.9+ compatible.** +- **All code must be Python 3.10+ compatible.** - **All new code must have full type annotations and numpy-style docstrings.** - **Backend consistency:** Ensure all backends (pickle, memory, mongo, sql, redis) are supported.\*\* - **Validation:** Test examples in this file work: `python -c "from cachier import cachier; ..."` should succeed. diff --git a/pyproject.toml b/pyproject.toml index b3565e8e..d249de33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,14 +24,13 @@ license = { file = "LICENSE" } authors = [ { name = "Shay Palachy Affek", email = 'shay.palachy@gmail.com' }, ] -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -75,7 +74,7 @@ namespaces = false # to disable scanning PEP 420 namespaces (true by default) # --- ruff --- [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 120 # Exclude a variety of commonly ignored directories. exclude = [ From e54da5395d4bca07ecb69039c57565501334d5be Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Wed, 18 Feb 2026 19:09:11 +0200 Subject: [PATCH 3/6] fix: add explicit strict flag to zip in arg mapping --- src/cachier/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cachier/core.py b/src/cachier/core.py index f0db36e8..3ab79f62 100644 --- a/src/cachier/core.py +++ b/src/cachier/core.py @@ -125,7 +125,7 @@ def _convert_args_kwargs(func, _is_method: bool, args: tuple, kwds: dict) -> dic # Map as many args as possible to regular parameters num_regular = len(params_to_use) - args_as_kw = dict(zip(params_to_use, args_to_map[:num_regular])) + args_as_kw = dict(zip(params_to_use, args_to_map[:num_regular], strict=False)) # Handle variadic positional arguments # Store them with indexed keys like __varargs_0__, __varargs_1__, etc. From 4d52816f6a4ad957560bf81115d7c097c8742023 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Wed, 18 Feb 2026 19:34:27 +0200 Subject: [PATCH 4/6] revert weird addition --- src/cachier/core.py | 72 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/src/cachier/core.py b/src/cachier/core.py index 3ab79f62..10756df3 100644 --- a/src/cachier/core.py +++ b/src/cachier/core.py @@ -65,7 +65,9 @@ async def _function_thread_async(core: _BaseCore, key, func, args, kwds): print(f"Function call failed with the following exception:\n{exc}") -def _calc_entry(core: _BaseCore, key, func, args, kwds, printer=lambda *_: None) -> Optional[Any]: +def _calc_entry( + core: _BaseCore, key, func, args, kwds, printer=lambda *_: None +) -> Optional[Any]: core.mark_entry_being_calculated(key) try: func_res = func(*args, **kwds) @@ -77,7 +79,9 @@ def _calc_entry(core: _BaseCore, key, func, args, kwds, printer=lambda *_: None) core.mark_entry_not_calculated(key) -async def _calc_entry_async(core: _BaseCore, key, func, args, kwds, printer=lambda *_: None) -> Optional[Any]: +async def _calc_entry_async( + core: _BaseCore, key, func, args, kwds, printer=lambda *_: None +) -> Optional[Any]: await core.amark_entry_being_calculated(key) try: func_res = await func(*args, **kwds) @@ -125,7 +129,7 @@ def _convert_args_kwargs(func, _is_method: bool, args: tuple, kwds: dict) -> dic # Map as many args as possible to regular parameters num_regular = len(params_to_use) - args_as_kw = dict(zip(params_to_use, args_to_map[:num_regular], strict=False)) + args_as_kw = dict(zip(params_to_use, args_to_map[:num_regular])) # Handle variadic positional arguments # Store them with indexed keys like __varargs_0__, __varargs_1__, etc. @@ -135,7 +139,11 @@ def _convert_args_kwargs(func, _is_method: bool, args: tuple, kwds: dict) -> dic args_as_kw[f"__varargs_{i}__"] = arg # Init with default values - kwargs = {k: v.default for k, v in sig.parameters.items() if v.default is not inspect.Parameter.empty} + kwargs = { + k: v.default + for k, v in sig.parameters.items() + if v.default is not inspect.Parameter.empty + } # Merge args expanded as kwargs and the original kwds kwargs.update(args_as_kw) @@ -160,7 +168,10 @@ def _is_async_redis_client(client: Any) -> bool: if client is None: return False method_names = ("hgetall", "hset", "keys", "delete", "hget") - return all(inspect.iscoroutinefunction(getattr(client, name, None)) for name in method_names) + return all( + inspect.iscoroutinefunction(getattr(client, name, None)) + for name in method_names + ) def cachier( @@ -263,7 +274,9 @@ def cachier( # Update parameters with defaults if input is None backend = _update_with_defaults(backend, "backend") mongetter = _update_with_defaults(mongetter, "mongetter") - size_limit_bytes = parse_bytes(_update_with_defaults(entry_size_limit, "entry_size_limit")) + size_limit_bytes = parse_bytes( + _update_with_defaults(entry_size_limit, "entry_size_limit") + ) # Override the backend parameter if a mongetter is provided. if callable(mongetter): backend = "mongo" @@ -286,7 +299,9 @@ def cachier( ) elif backend == "memory": core = _MemoryCore( - hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout, entry_size_limit=size_limit_bytes + hash_func=hash_func, + wait_for_calc_timeout=wait_for_calc_timeout, + entry_size_limit=size_limit_bytes, ) elif backend == "sql": core = _SQLCore( @@ -328,7 +343,9 @@ def _cachier_decorator(func): raise TypeError(msg) else: if callable(redis_client) and inspect.iscoroutinefunction(redis_client): - msg = "Async redis_client callable requires an async cached function." + msg = ( + "Async redis_client callable requires an async cached function." + ) raise TypeError(msg) if _is_async_redis_client(redis_client): msg = "Async Redis client requires an async cached function." @@ -384,9 +401,13 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds): _stale_after = _update_with_defaults(stale_after, "stale_after", kwds) _next_time = _update_with_defaults(next_time, "next_time", kwds) _cleanup_flag = _update_with_defaults(cleanup_stale, "cleanup_stale", kwds) - _cleanup_interval_val = _update_with_defaults(cleanup_interval, "cleanup_interval", kwds) + _cleanup_interval_val = _update_with_defaults( + cleanup_interval, "cleanup_interval", kwds + ) # merge args expanded as kwargs and the original kwds - kwargs = _convert_args_kwargs(func, _is_method=core.func_is_method, args=args, kwds=kwds) + kwargs = _convert_args_kwargs( + func, _is_method=core.func_is_method, args=args, kwds=kwds + ) if _cleanup_flag: now = datetime.now() @@ -401,7 +422,9 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds): from .config import _global_params if ignore_cache or not _global_params.caching_enabled: - return func(args[0], **kwargs) if core.func_is_method else func(**kwargs) + return ( + func(args[0], **kwargs) if core.func_is_method else func(**kwargs) + ) key, entry = core.get_entry((), kwargs) if overwrite_cache: return _calc_entry(core, key, func, args, kwds, _print) @@ -439,7 +462,9 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds): _print("Async calc and return stale") core.mark_entry_being_calculated(key) try: - _get_executor().submit(_function_thread, core, key, func, args, kwds) + _get_executor().submit( + _function_thread, core, key, func, args, kwds + ) finally: core.mark_entry_not_calculated(key) return entry.value @@ -471,9 +496,13 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds): _stale_after = _update_with_defaults(stale_after, "stale_after", kwds) _next_time = _update_with_defaults(next_time, "next_time", kwds) _cleanup_flag = _update_with_defaults(cleanup_stale, "cleanup_stale", kwds) - _cleanup_interval_val = _update_with_defaults(cleanup_interval, "cleanup_interval", kwds) + _cleanup_interval_val = _update_with_defaults( + cleanup_interval, "cleanup_interval", kwds + ) # merge args expanded as kwargs and the original kwds - kwargs = _convert_args_kwargs(func, _is_method=core.func_is_method, args=args, kwds=kwds) + kwargs = _convert_args_kwargs( + func, _is_method=core.func_is_method, args=args, kwds=kwds + ) if _cleanup_flag: now = datetime.now() @@ -488,7 +517,11 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds): from .config import _global_params if ignore_cache or not _global_params.caching_enabled: - return await func(args[0], **kwargs) if core.func_is_method else await func(**kwargs) + return ( + await func(args[0], **kwargs) + if core.func_is_method + else await func(**kwargs) + ) key, entry = await core.aget_entry((), kwargs) if overwrite_cache: result = await _calc_entry_async(core, key, func, args, kwds, _print) @@ -522,7 +555,9 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds): # Background task will update cache when complete await core.amark_entry_being_calculated(key) # Use asyncio.create_task for background execution - asyncio.create_task(_function_thread_async(core, key, func, args, kwds)) + asyncio.create_task( + _function_thread_async(core, key, func, args, kwds) + ) await core.amark_entry_not_calculated(key) return entry.value _print("Calling decorated function and waiting") @@ -549,6 +584,7 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds): @wraps(func) async def func_wrapper(*args, **kwargs): return await _call_async(*args, **kwargs) + else: @wraps(func) @@ -585,7 +621,9 @@ def _precache_value(*args, value_to_cache, **kwds): # noqa: D417 """ # merge args expanded as kwargs and the original kwds - kwargs = _convert_args_kwargs(func, _is_method=core.func_is_method, args=args, kwds=kwds) + kwargs = _convert_args_kwargs( + func, _is_method=core.func_is_method, args=args, kwds=kwds + ) return core.precache_value((), kwargs, value_to_cache) func_wrapper.clear_cache = _clear_cache From 38b0750b340425c2c34d4a2a15524f570a1ea93f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:34:46 +0000 Subject: [PATCH 5/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/cachier/core.py | 65 +++++++++++---------------------------------- 1 file changed, 15 insertions(+), 50 deletions(-) diff --git a/src/cachier/core.py b/src/cachier/core.py index 10756df3..122d9c7b 100644 --- a/src/cachier/core.py +++ b/src/cachier/core.py @@ -65,9 +65,7 @@ async def _function_thread_async(core: _BaseCore, key, func, args, kwds): print(f"Function call failed with the following exception:\n{exc}") -def _calc_entry( - core: _BaseCore, key, func, args, kwds, printer=lambda *_: None -) -> Optional[Any]: +def _calc_entry(core: _BaseCore, key, func, args, kwds, printer=lambda *_: None) -> Optional[Any]: core.mark_entry_being_calculated(key) try: func_res = func(*args, **kwds) @@ -79,9 +77,7 @@ def _calc_entry( core.mark_entry_not_calculated(key) -async def _calc_entry_async( - core: _BaseCore, key, func, args, kwds, printer=lambda *_: None -) -> Optional[Any]: +async def _calc_entry_async(core: _BaseCore, key, func, args, kwds, printer=lambda *_: None) -> Optional[Any]: await core.amark_entry_being_calculated(key) try: func_res = await func(*args, **kwds) @@ -139,11 +135,7 @@ def _convert_args_kwargs(func, _is_method: bool, args: tuple, kwds: dict) -> dic args_as_kw[f"__varargs_{i}__"] = arg # Init with default values - kwargs = { - k: v.default - for k, v in sig.parameters.items() - if v.default is not inspect.Parameter.empty - } + kwargs = {k: v.default for k, v in sig.parameters.items() if v.default is not inspect.Parameter.empty} # Merge args expanded as kwargs and the original kwds kwargs.update(args_as_kw) @@ -168,10 +160,7 @@ def _is_async_redis_client(client: Any) -> bool: if client is None: return False method_names = ("hgetall", "hset", "keys", "delete", "hget") - return all( - inspect.iscoroutinefunction(getattr(client, name, None)) - for name in method_names - ) + return all(inspect.iscoroutinefunction(getattr(client, name, None)) for name in method_names) def cachier( @@ -274,9 +263,7 @@ def cachier( # Update parameters with defaults if input is None backend = _update_with_defaults(backend, "backend") mongetter = _update_with_defaults(mongetter, "mongetter") - size_limit_bytes = parse_bytes( - _update_with_defaults(entry_size_limit, "entry_size_limit") - ) + size_limit_bytes = parse_bytes(_update_with_defaults(entry_size_limit, "entry_size_limit")) # Override the backend parameter if a mongetter is provided. if callable(mongetter): backend = "mongo" @@ -343,9 +330,7 @@ def _cachier_decorator(func): raise TypeError(msg) else: if callable(redis_client) and inspect.iscoroutinefunction(redis_client): - msg = ( - "Async redis_client callable requires an async cached function." - ) + msg = "Async redis_client callable requires an async cached function." raise TypeError(msg) if _is_async_redis_client(redis_client): msg = "Async Redis client requires an async cached function." @@ -401,13 +386,9 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds): _stale_after = _update_with_defaults(stale_after, "stale_after", kwds) _next_time = _update_with_defaults(next_time, "next_time", kwds) _cleanup_flag = _update_with_defaults(cleanup_stale, "cleanup_stale", kwds) - _cleanup_interval_val = _update_with_defaults( - cleanup_interval, "cleanup_interval", kwds - ) + _cleanup_interval_val = _update_with_defaults(cleanup_interval, "cleanup_interval", kwds) # merge args expanded as kwargs and the original kwds - kwargs = _convert_args_kwargs( - func, _is_method=core.func_is_method, args=args, kwds=kwds - ) + kwargs = _convert_args_kwargs(func, _is_method=core.func_is_method, args=args, kwds=kwds) if _cleanup_flag: now = datetime.now() @@ -422,9 +403,7 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds): from .config import _global_params if ignore_cache or not _global_params.caching_enabled: - return ( - func(args[0], **kwargs) if core.func_is_method else func(**kwargs) - ) + return func(args[0], **kwargs) if core.func_is_method else func(**kwargs) key, entry = core.get_entry((), kwargs) if overwrite_cache: return _calc_entry(core, key, func, args, kwds, _print) @@ -462,9 +441,7 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds): _print("Async calc and return stale") core.mark_entry_being_calculated(key) try: - _get_executor().submit( - _function_thread, core, key, func, args, kwds - ) + _get_executor().submit(_function_thread, core, key, func, args, kwds) finally: core.mark_entry_not_calculated(key) return entry.value @@ -496,13 +473,9 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds): _stale_after = _update_with_defaults(stale_after, "stale_after", kwds) _next_time = _update_with_defaults(next_time, "next_time", kwds) _cleanup_flag = _update_with_defaults(cleanup_stale, "cleanup_stale", kwds) - _cleanup_interval_val = _update_with_defaults( - cleanup_interval, "cleanup_interval", kwds - ) + _cleanup_interval_val = _update_with_defaults(cleanup_interval, "cleanup_interval", kwds) # merge args expanded as kwargs and the original kwds - kwargs = _convert_args_kwargs( - func, _is_method=core.func_is_method, args=args, kwds=kwds - ) + kwargs = _convert_args_kwargs(func, _is_method=core.func_is_method, args=args, kwds=kwds) if _cleanup_flag: now = datetime.now() @@ -517,11 +490,7 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds): from .config import _global_params if ignore_cache or not _global_params.caching_enabled: - return ( - await func(args[0], **kwargs) - if core.func_is_method - else await func(**kwargs) - ) + return await func(args[0], **kwargs) if core.func_is_method else await func(**kwargs) key, entry = await core.aget_entry((), kwargs) if overwrite_cache: result = await _calc_entry_async(core, key, func, args, kwds, _print) @@ -555,9 +524,7 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds): # Background task will update cache when complete await core.amark_entry_being_calculated(key) # Use asyncio.create_task for background execution - asyncio.create_task( - _function_thread_async(core, key, func, args, kwds) - ) + asyncio.create_task(_function_thread_async(core, key, func, args, kwds)) await core.amark_entry_not_calculated(key) return entry.value _print("Calling decorated function and waiting") @@ -621,9 +588,7 @@ def _precache_value(*args, value_to_cache, **kwds): # noqa: D417 """ # merge args expanded as kwargs and the original kwds - kwargs = _convert_args_kwargs( - func, _is_method=core.func_is_method, args=args, kwds=kwds - ) + kwargs = _convert_args_kwargs(func, _is_method=core.func_is_method, args=args, kwds=kwds) return core.precache_value((), kwargs, value_to_cache) func_wrapper.clear_cache = _clear_cache From 2cd30c282bf12e2d2c64ea2ea6b3ef5c917b04c7 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Wed, 18 Feb 2026 19:36:21 +0200 Subject: [PATCH 6/6] fix: add explicit strict flag to zip in arg mapping --- src/cachier/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cachier/core.py b/src/cachier/core.py index 122d9c7b..34830d3b 100644 --- a/src/cachier/core.py +++ b/src/cachier/core.py @@ -125,7 +125,7 @@ def _convert_args_kwargs(func, _is_method: bool, args: tuple, kwds: dict) -> dic # Map as many args as possible to regular parameters num_regular = len(params_to_use) - args_as_kw = dict(zip(params_to_use, args_to_map[:num_regular])) + args_as_kw = dict(zip(params_to_use, args_to_map[:num_regular], strict=False)) # Handle variadic positional arguments # Store them with indexed keys like __varargs_0__, __varargs_1__, etc.