Skip to content

Commit 9f3c0ce

Browse files
committed
Merge remote-tracking branch 'origin/master' into copilot/add-cache-analytics-framework
2 parents 75d22b3 + 7bf8834 commit 9f3c0ce

File tree

16 files changed

+300
-92
lines changed

16 files changed

+300
-92
lines changed

AGENTS.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,10 +439,13 @@ ______________________________________________________________________
439439
- `make test-all-local` - Test all backends with Docker
440440
- `make test-external` - Test all external backends
441441
- `make test-mongo-local` - Test MongoDB only
442+
- `make test-mongo-inmemory` - Test MongoDB marker set with local/in-memory setup
443+
- `make test-mongo-also-local` - Test MongoDB together with local core tests
442444
- `make test-redis-local` - Test Redis only
443445
- `make test-sql-local` - Test SQL only
444446
- `make services-start` - Start all Docker containers
445447
- `make services-stop` - Stop all Docker containers
448+
- `make services-logs` - Tail logs for Dockerized test services
446449

447450
**Available Cores:**
448451

@@ -462,8 +465,11 @@ ______________________________________________________________________
462465
- `-k, --keep-running` - Keep containers running after tests
463466
- `-h, --html-coverage` - Generate HTML coverage report
464467
- `-f, --files` - Run only specific test files
468+
- `-p, --parallel` - Run tests with `pytest-xdist`
469+
- `-w, --workers` - Set number of parallel workers (default: `auto`)
465470

466-
**Note:** External backends (MongoDB, Redis, SQL) require Docker. S3, memory, and pickle backends work without Docker.
471+
**Note:** Redis and SQL backends require Docker. MongoDB tests run in-memory by default (no Docker needed) when invoked directly (for example, `pytest -m mongo` or `make test-mongo-inmemory` without `CACHIER_TEST_VS_DOCKERIZED_MONGO` set). When using `./scripts/test-local.sh mongo` or including `mongo` in the core list, MongoDB is always run via a Docker container and requires Docker. S3, memory, and pickle backends work without Docker.
472+
You can also set cores with `CACHIER_TEST_CORES="mongo redis" ./scripts/test-local.sh`, in which case both MongoDB and Redis will run via Docker.
467473

468474
______________________________________________________________________
469475

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ test-mongo-inmemory:
6969

7070
test-mongo-docker:
7171
@echo "Running MongoDB tests against Docker MongoDB..."
72-
./scripts/test-mongo-local.sh
72+
./scripts/test-local.sh mongo
7373

7474
test-mongo-local: test-mongo-docker
7575

7676
test-mongo-also-local:
7777
@echo "Running MongoDB tests with local core tests..."
78-
./scripts/test-mongo-local.sh --mode also-local
78+
./scripts/test-local.sh mongo memory pickle
7979

8080
# New unified testing targets
8181
test-local:

README.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ You can add a default, pickle-based, persistent cache to your function - meaning
9595
"""Your function now has a persistent cache mapped by argument values!"""
9696
return {'arg1': arg1, 'arg2': arg2}
9797
98-
Class and object methods can also be cached. Cachier will automatically ignore the `self` parameter when determining the cache key for an object method. **This means that methods will be cached across all instances of an object, which may not be what you want.**
98+
Class and object methods can also be cached. Cachier will automatically ignore the ``self`` parameter when determining the cache key for an object method. **This means that methods will be cached across all instances of an object, which may not be what you want.** Because this is a common source of bugs, ``@cachier`` raises a ``TypeError`` by default when applied to an instance method (a function whose first parameter is named ``self``). This error is raised when ``@cachier`` is applied (at class definition time), not when the method is called. To opt in to cross-instance cache sharing, pass ``allow_non_static_methods=True``.
9999

100100
.. code-block:: python
101101
@@ -108,17 +108,18 @@ Class and object methods can also be cached. Cachier will automatically ignore t
108108
return arg_1 + arg_2
109109
110110
# Instance method does not depend on object's internal state, so good to cache
111-
@cachier()
111+
@cachier(allow_non_static_methods=True)
112112
def good_usage_1(self, arg_1, arg_2):
113113
return arg_1 + arg_2
114114
115115
# Instance method is calling external service, probably okay to cache
116-
@cachier()
116+
@cachier(allow_non_static_methods=True)
117117
def good_usage_2(self, arg_1, arg_2):
118118
result = self.call_api(arg_1, arg_2)
119119
return result
120120
121121
# Instance method relies on object attribute, NOT good to cache
122+
# @cachier() would raise TypeError here -- this is intentional
122123
@cachier()
123124
def bad_usage(self, arg_1, arg_2):
124125
return arg_1 + arg_2 + self.arg_3
@@ -149,6 +150,7 @@ The following parameters will only be applied to decorators defined after `set_d
149150
* `pickle_reload`
150151
* `separate_files`
151152
* `entry_size_limit`
153+
* `allow_non_static_methods`
152154

153155
These parameters can be changed at any time and they will apply to all decorators:
154156

src/cachier/config.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class Params:
6666
cleanup_stale: bool = False
6767
cleanup_interval: timedelta = timedelta(days=1)
6868
entry_size_limit: Optional[int] = None
69+
allow_non_static_methods: bool = False
6970

7071

7172
_global_params = Params()
@@ -130,7 +131,14 @@ def set_global_params(**params: Any) -> None:
130131
'cleanup_interval', and 'caching_enabled'. In some cores, if the
131132
decorator was created without concrete value for 'wait_for_calc_timeout',
132133
calls that check calculation timeouts will fall back to the global
133-
'wait_for_calc_timeout' as well.
134+
'wait_for_calc_timeout' as well. 'allow_non_static_methods'
135+
(decoration-time only) controls whether instance methods are
136+
permitted; it is read once when @cachier is applied, not on each call.
137+
138+
Note that ``allow_non_static_methods`` is a **decoration-time**
139+
parameter: it is checked once when the ``@cachier`` decorator is
140+
applied and is not re-read on each function call. Changing it via
141+
``set_global_params`` only affects decorators created after the call.
134142
135143
"""
136144
import cachier

src/cachier/core.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ def cachier(
218218
cleanup_stale: Optional[bool] = None,
219219
cleanup_interval: Optional[timedelta] = None,
220220
entry_size_limit: Optional[Union[int, str]] = None,
221+
allow_non_static_methods: Optional[bool] = None,
221222
enable_metrics: bool = False,
222223
metrics_sampling_rate: float = 1.0,
223224
):
@@ -307,6 +308,13 @@ def cachier(
307308
Maximum serialized size of a cached value. Values exceeding the limit
308309
are returned but not cached. Human readable strings like ``"10MB"`` are
309310
allowed.
311+
allow_non_static_methods : bool, optional
312+
If True, allows ``@cachier`` to decorate instance methods (functions
313+
whose first parameter is named ``self``). By default, decorating an
314+
instance method raises ``TypeError`` because the ``self`` argument is
315+
ignored for cache-key computation, meaning all instances share the
316+
same cache -- which is rarely the intended behaviour. Set this to
317+
``True`` only when cross-instance cache sharing is intentional.
310318
enable_metrics: bool, optional
311319
Enable metrics collection for this cached function. When enabled,
312320
cache hits, misses, latencies, and other performance metrics are tracked. Defaults to False.
@@ -394,6 +402,23 @@ def cachier(
394402

395403
def _cachier_decorator(func):
396404
core.set_func(func)
405+
406+
# Guard: raise TypeError when decorating an instance method unless
407+
# explicitly opted in. The 'self' parameter is ignored for cache-key
408+
# computation, so all instances share the same cache.
409+
if core.func_is_method:
410+
_allow_methods = _update_with_defaults(allow_non_static_methods, "allow_non_static_methods")
411+
if not _allow_methods:
412+
raise TypeError(
413+
f"@cachier cannot decorate instance method "
414+
f"'{func.__qualname__}' because the 'self' parameter is "
415+
"excluded from cache-key computation and all instances "
416+
"would share a single cache. Pass allow_non_static_methods=True "
417+
"to the decorator or call "
418+
"set_global_params(allow_non_static_methods=True) if "
419+
"cross-instance cache sharing is intentional."
420+
)
421+
397422
is_coroutine = inspect.iscoroutinefunction(func)
398423

399424
if backend == "mongo":

src/cachier/cores/base.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,23 @@ def __init__(
5252
self.wait_for_calc_timeout = wait_for_calc_timeout
5353
self.lock = threading.RLock()
5454
self.entry_size_limit = entry_size_limit
55+
self.func_is_method: bool = False
5556
self.metrics = metrics
5657

5758
def set_func(self, func):
5859
"""Set the function this core will use.
5960
60-
This has to be set before any method is called. Also determine if the function is an object method.
61+
This must be called before any other method is invoked. In addition
62+
to storing ``func`` on the instance, this method inspects the
63+
function's signature and sets ``self.func_is_method`` to ``True``
64+
when the first parameter is named ``"self"``.
65+
66+
Notes
67+
-----
68+
Detection is name-based: only ``func_params[0] == "self"`` is
69+
checked. ``@classmethod`` functions whose first parameter is
70+
conventionally named ``cls`` are not detected as methods --
71+
this is a known gap.
6172
6273
"""
6374
# unwrap if the function is functools.partial

tests/conftest.py

Lines changed: 86 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,103 +3,119 @@
33
import logging
44
import os
55
import re
6+
from typing import Optional
67
from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
78

89
import pytest
910

1011
logger = logging.getLogger(__name__)
1112

1213

13-
def _worker_schema_name(worker_id: str) -> str | None:
14+
def _worker_schema_name(worker_id: str) -> Optional[str]:
1415
"""Return a safe SQL schema name for an xdist worker ID."""
1516
match = re.fullmatch(r"gw(\d+)", worker_id)
1617
if match is None:
1718
return None
1819
return f"test_worker_{match.group(1)}"
1920

2021

21-
@pytest.fixture(autouse=True)
22-
def inject_worker_schema_for_sql_tests(monkeypatch, request):
23-
"""Automatically inject worker-specific schema into SQL connection string.
22+
def _build_worker_url(original_url: str, schema_name: str) -> str:
23+
"""Return a copy of original_url with search_path set to schema_name."""
24+
parsed = urlparse(original_url)
25+
query_params = parse_qs(parsed.query)
26+
27+
if "options" in query_params:
28+
current_options = unquote(query_params["options"][0])
29+
new_options = f"{current_options} -csearch_path={schema_name}"
30+
else:
31+
new_options = f"-csearch_path={schema_name}"
32+
33+
query_params["options"] = [new_options]
34+
new_query = urlencode(query_params, doseq=True)
35+
return urlunparse(
36+
(
37+
parsed.scheme,
38+
parsed.netloc,
39+
parsed.path,
40+
parsed.params,
41+
new_query,
42+
parsed.fragment,
43+
)
44+
)
45+
2446

25-
This fixture enables parallel SQL test execution by giving each pytest-xdist worker its own PostgreSQL schema,
26-
preventing table creation conflicts.
47+
@pytest.fixture(scope="session")
48+
def worker_sql_connection(request: pytest.FixtureRequest) -> Optional[str]:
49+
"""Create the worker-specific PostgreSQL schema once per xdist worker session.
50+
51+
Returns the worker-specific connection URL, or None when schema isolation is not
52+
needed (serial run or non-PostgreSQL backend). The schema is created with
53+
``CREATE SCHEMA IF NOT EXISTS`` so this fixture is safe to run even if the schema
54+
already exists from a previous interrupted run.
55+
56+
A non-None return value means "use this URL"; schema creation is attempted but may
57+
fail silently (e.g. if SQLAlchemy is not installed or the DB is unreachable). Tests
58+
that depend on the schema will fail at the DB level with a diagnostic error.
2759
2860
"""
29-
# Only apply to SQL tests
30-
if "sql" not in request.node.keywords:
31-
yield
32-
return
61+
# Avoid touching SQL backends entirely when no SQL tests are collected.
62+
has_sql_tests = any("sql" in item.keywords for item in request.session.items)
63+
if not has_sql_tests:
64+
return None
3365

3466
worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master")
35-
3667
if worker_id == "master":
37-
# Not running in parallel, no schema isolation needed
38-
yield
39-
return
68+
return None
4069

41-
# Get the original SQL connection string
4270
original_url = os.environ.get("SQLALCHEMY_DATABASE_URL", "sqlite:///:memory:")
71+
if "postgresql" not in original_url:
72+
return None
4373

44-
if "postgresql" in original_url:
45-
# Create worker-specific schema name
46-
schema_name = _worker_schema_name(worker_id)
47-
if schema_name is None:
48-
logger.warning("Unexpected worker ID for SQL schema isolation: %s", worker_id)
49-
yield
50-
return
51-
52-
# Parse the URL
53-
parsed = urlparse(original_url)
54-
55-
# Get existing query parameters
56-
query_params = parse_qs(parsed.query)
57-
58-
# Add or update the options parameter to set search_path
59-
if "options" in query_params:
60-
# Append to existing options
61-
current_options = unquote(query_params["options"][0])
62-
new_options = f"{current_options} -csearch_path={schema_name}"
63-
else:
64-
# Create new options
65-
new_options = f"-csearch_path={schema_name}"
66-
67-
query_params["options"] = [new_options]
68-
69-
# Rebuild the URL with updated query parameters
70-
new_query = urlencode(query_params, doseq=True)
71-
new_url = urlunparse(
72-
(
73-
parsed.scheme,
74-
parsed.netloc,
75-
parsed.path,
76-
parsed.params,
77-
new_query,
78-
parsed.fragment,
79-
)
80-
)
74+
schema_name = _worker_schema_name(worker_id)
75+
if schema_name is None:
76+
logger.warning("Unexpected worker ID for SQL schema isolation: %s", worker_id)
77+
return None
78+
79+
new_url = _build_worker_url(original_url, schema_name)
8180

82-
# Override both the environment variable and the module constant
83-
monkeypatch.setenv("SQLALCHEMY_DATABASE_URL", new_url)
81+
engine = None
82+
try:
83+
from sqlalchemy import create_engine, text
8484

85-
# Also patch the SQL_CONN_STR constant used in tests
86-
import tests.sql_tests.test_sql_core
85+
engine = create_engine(original_url)
86+
with engine.connect() as conn:
87+
conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_name}"))
88+
conn.commit()
89+
except Exception as e:
90+
logger.debug("Failed to create schema %s: %s", schema_name, e)
91+
finally:
92+
if engine is not None:
93+
engine.dispose()
8794

88-
monkeypatch.setattr(tests.sql_tests.test_sql_core, "SQL_CONN_STR", new_url)
95+
return new_url
8996

90-
# Ensure schema creation by creating it before tests run
91-
try:
92-
from sqlalchemy import create_engine, text
9397

94-
# Use original URL to create schema (without search_path)
95-
engine = create_engine(original_url)
96-
with engine.connect() as conn:
97-
conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_name}"))
98-
conn.commit()
99-
engine.dispose()
100-
except Exception as e:
101-
# If we can't create the schema, the test will fail anyway
102-
logger.debug(f"Failed to create schema {schema_name}: {e}")
98+
@pytest.fixture(autouse=True)
99+
def inject_worker_schema_for_sql_tests(monkeypatch, request, worker_sql_connection):
100+
"""Automatically inject worker-specific schema into SQL connection string.
101+
102+
This fixture enables parallel SQL test execution by giving each pytest-xdist worker
103+
its own PostgreSQL schema, preventing table creation conflicts.
104+
105+
Schema creation is handled once per worker session by
106+
:func:`worker_sql_connection`. This fixture only performs lightweight
107+
per-test monkeypatching of the environment variable and module constant.
108+
109+
"""
110+
if "sql" not in request.node.keywords or worker_sql_connection is None:
111+
yield
112+
return
113+
114+
monkeypatch.setenv("SQLALCHEMY_DATABASE_URL", worker_sql_connection)
115+
116+
import tests.sql_tests.test_sql_core
117+
118+
monkeypatch.setattr(tests.sql_tests.test_sql_core, "SQL_CONN_STR", worker_sql_connection)
103119

104120
yield
105121

@@ -193,4 +209,4 @@ def cleanup_test_schemas(request):
193209
engine.dispose()
194210
except Exception as e:
195211
# If cleanup fails, it's not critical
196-
logger.debug(f"Failed to cleanup schema {schema_name}: {e}")
212+
logger.debug("Failed to cleanup schema %s: %s", schema_name, e)

tests/mongo_tests/test_async_mongo_core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ async def async_mongetter():
7676
call_count = 0
7777

7878
class _MongoMethods:
79-
@cachier(mongetter=async_mongetter)
79+
@cachier(mongetter=async_mongetter, allow_non_static_methods=True)
8080
async def async_cached_mongo_method_args_kwargs(self, x: int, y: int) -> int:
8181
nonlocal call_count
8282
call_count += 1

tests/redis_tests/test_async_redis_core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ async def get_redis_client():
8484
call_count = 0
8585

8686
class _RedisMethods:
87-
@cachier(backend="redis", redis_client=get_redis_client)
87+
@cachier(backend="redis", redis_client=get_redis_client, allow_non_static_methods=True)
8888
async def async_cached_redis_method_args_kwargs(self, x: int, y: int) -> int:
8989
nonlocal call_count
9090
call_count += 1

0 commit comments

Comments
 (0)