easyapi-django 0.32__tar.gz → 0.34__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {easyapi_django-0.32/easyapi_django.egg-info → easyapi_django-0.34}/PKG-INFO +9 -7
  2. {easyapi_django-0.32 → easyapi_django-0.34}/README.md +8 -6
  3. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/base.py +46 -9
  4. easyapi_django-0.34/easyapi/redis_config.py +106 -0
  5. {easyapi_django-0.32 → easyapi_django-0.34/easyapi_django.egg-info}/PKG-INFO +9 -7
  6. {easyapi_django-0.32 → easyapi_django-0.34}/pyproject.toml +1 -1
  7. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_cache.py +44 -0
  8. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_cache_scope.py +31 -8
  9. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_redis_config.py +35 -0
  10. easyapi_django-0.32/easyapi/redis_config.py +0 -64
  11. {easyapi_django-0.32 → easyapi_django-0.34}/LICENSE +0 -0
  12. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/__init__.py +0 -0
  13. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/auth.py +0 -0
  14. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/blocking.py +0 -0
  15. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/calc.py +0 -0
  16. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/calc_resource.py +0 -0
  17. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/client_ip.py +0 -0
  18. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/constants.py +0 -0
  19. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/dates.py +0 -0
  20. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/exception.py +0 -0
  21. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/filters.py +0 -0
  22. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/helpers.py +0 -0
  23. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/management/__init__.py +0 -0
  24. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/management/commands/__init__.py +0 -0
  25. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/management/commands/mcp_serve.py +0 -0
  26. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/__init__.py +0 -0
  27. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/bridge.py +0 -0
  28. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/protocol.py +0 -0
  29. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/resource.py +0 -0
  30. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/tools.py +0 -0
  31. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/middleware.py +0 -0
  32. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/openapi.py +0 -0
  33. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/orm/__init__.py +0 -0
  34. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/rate_limit.py +0 -0
  35. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/routes.py +0 -0
  36. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/schemas.py +0 -0
  37. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/security.py +0 -0
  38. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/serializer.py +0 -0
  39. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/tenant/__init__.py +0 -0
  40. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/tenant/db_router.py +0 -0
  41. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/tenant/registry.py +0 -0
  42. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/tenant/tenant.py +0 -0
  43. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/util.py +0 -0
  44. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/ws.py +0 -0
  45. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi_django.egg-info/SOURCES.txt +0 -0
  46. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi_django.egg-info/dependency_links.txt +0 -0
  47. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi_django.egg-info/requires.txt +0 -0
  48. {easyapi_django-0.32 → easyapi_django-0.34}/easyapi_django.egg-info/top_level.txt +0 -0
  49. {easyapi_django-0.32 → easyapi_django-0.34}/setup.cfg +0 -0
  50. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_api_key_resolver.py +0 -0
  51. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_auth.py +0 -0
  52. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_base_init.py +0 -0
  53. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_block.py +0 -0
  54. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_cache_auto_account_scope.py +0 -0
  55. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_client_ip.py +0 -0
  56. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_dispatch_error_handling.py +0 -0
  57. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_exception_render.py +0 -0
  58. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_exports.py +0 -0
  59. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_filter_validation.py +0 -0
  60. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_filters.py +0 -0
  61. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_get_obj_m2m_only.py +0 -0
  62. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_helpers.py +0 -0
  63. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_index_route.py +0 -0
  64. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_mcp.py +0 -0
  65. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_mcp_middleware_chain.py +0 -0
  66. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_normalize_field.py +0 -0
  67. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_openapi.py +0 -0
  68. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_owner_field.py +0 -0
  69. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_return_result_falsy.py +0 -0
  70. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_routes_gate.py +0 -0
  71. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_schemas.py +0 -0
  72. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_security.py +0 -0
  73. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_serializer.py +0 -0
  74. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_tenant_connection.py +0 -0
  75. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_tenant_registry.py +0 -0
  76. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_util.py +0 -0
  77. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_ws_channels.py +0 -0
  78. {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_ws_optional.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyapi_django
3
- Version: 0.32
3
+ Version: 0.34
4
4
  Summary: A simple rest api generator for django based on models
5
5
  Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
6
6
  License: MIT
@@ -278,12 +278,14 @@ class TaskResource(BaseResource):
278
278
  cache_scope_fields = ['space_id', ('account', 'plan_id')]
279
279
  ```
280
280
 
281
- Lenient by default: if the request has authenticated context but a
282
- configured field is **missing** from the session payload, the framework
283
- logs a `WARNING` (logger `easyapi.base`) and **skips the scope fold** —
284
- the request still serves, the cache may be shared across scopes until
285
- the operator updates the session payload. Anonymous requests also skip
286
- the fold. `None`, `0` and `''` count as present (a real value).
281
+ When a request has authenticated context but a configured scope field
282
+ is **missing** from the session payload, the framework logs a `WARNING`
283
+ (logger `easyapi.base`) and **disables cache for that request** — the
284
+ response is neither read from nor written to Redis. Sharing a key
285
+ across users when the scope can't be resolved would be a silent leak
286
+ across whatever dimension the operator was trying to protect.
287
+ Anonymous requests skip the fold cleanly (no warning, no leak).
288
+ `None`, `0` and `''` count as present (a real value).
287
289
 
288
290
  Use `before_cache` for the rare case that needs context outside
289
291
  `self.user` / `self.account`:
@@ -251,12 +251,14 @@ class TaskResource(BaseResource):
251
251
  cache_scope_fields = ['space_id', ('account', 'plan_id')]
252
252
  ```
253
253
 
254
- Lenient by default: if the request has authenticated context but a
255
- configured field is **missing** from the session payload, the framework
256
- logs a `WARNING` (logger `easyapi.base`) and **skips the scope fold** —
257
- the request still serves, the cache may be shared across scopes until
258
- the operator updates the session payload. Anonymous requests also skip
259
- the fold. `None`, `0` and `''` count as present (a real value).
254
+ When a request has authenticated context but a configured scope field
255
+ is **missing** from the session payload, the framework logs a `WARNING`
256
+ (logger `easyapi.base`) and **disables cache for that request** — the
257
+ response is neither read from nor written to Redis. Sharing a key
258
+ across users when the scope can't be resolved would be a silent leak
259
+ across whatever dimension the operator was trying to protect.
260
+ Anonymous requests skip the fold cleanly (no warning, no leak).
261
+ `None`, `0` and `''` count as present (a real value).
260
262
 
261
263
  Use `before_cache` for the rare case that needs context outside
262
264
  `self.user` / `self.account`:
@@ -375,16 +375,24 @@ class BaseResource(View):
375
375
  def _resolve_cache_scope(self):
376
376
  """Build the scope hash for the cache key.
377
377
 
378
- Returns ``None`` when there is nothing to fold (no scope fields
379
- configured, or no authenticated context to fold from). Raises
380
- ``HTTPException(500)`` if a configured field is missing from the
381
- session payload vazamento silencioso seria pior que 500.
378
+ Three outcomes:
379
+
380
+ - ``None`` *with cache enabled*: no scope fields configured, or
381
+ fully anonymous request. Cache continues normally; the key
382
+ simply has no scope segment.
383
+ - ``str`` segment (``'scope=...'``): fold succeeded.
384
+ - ``None`` *with cache disabled*: scope was configured but the
385
+ required field is missing from the session payload. The
386
+ framework logs a ``WARNING`` and **disables cache for this
387
+ request** (``self.cache = False``) so the response is neither
388
+ read from nor written to Redis. Sharing a cache key across
389
+ users when the scope can't be resolved would be a silent
390
+ leak across whatever dimension the operator was protecting.
382
391
  """
383
392
  if not self.cache_scope_fields:
384
393
  return None
385
394
 
386
395
  # Fully anonymous request → nothing to scope by, skip silently.
387
- # Strict mode applies the moment any auth context exists.
388
396
  if self.user is None and self.account is None:
389
397
  return None
390
398
 
@@ -395,18 +403,20 @@ class BaseResource(View):
395
403
  if container is None:
396
404
  logger.warning(
397
405
  "cache scope source %r missing from session for %s; "
398
- "skipping scope fold (cache may be shared across "
399
- "scopes — populate the session payload to fix)",
406
+ "disabling cache for this request (populate the "
407
+ "session payload to enable caching)",
400
408
  source, self.__class__.__name__,
401
409
  )
410
+ self.cache = False
402
411
  return None
403
412
  if field not in container:
404
413
  logger.warning(
405
414
  "cache scope field %r missing from %s session payload "
406
- "for %s; skipping scope fold (cache may be shared "
407
- "across scopes — populate the session payload to fix)",
415
+ "for %s; disabling cache for this request (populate "
416
+ "the session payload to enable caching)",
408
417
  field, source, self.__class__.__name__,
409
418
  )
419
+ self.cache = False
410
420
  return None
411
421
  parts.append(f'{source}.{field}={container[field]!r}')
412
422
 
@@ -597,6 +607,12 @@ class BaseResource(View):
597
607
 
598
608
  if type(response) in [dict, list]:
599
609
  response = await self.serialize(response)
610
+ elif self.cache and isinstance(response, JsonResponse):
611
+ # Handler returned a built JsonResponse — serialize() would
612
+ # short-circuit before save_cache, leaving the cache cold
613
+ # forever. Persist the response body directly so the next
614
+ # hit on this key is served by _serve_cache.
615
+ await self._save_response_cache(response)
600
616
 
601
617
  return response
602
618
 
@@ -734,6 +750,27 @@ class BaseResource(View):
734
750
  await redis.sadd(ns_key, self.cache_key)
735
751
  await redis.expire(ns_key, max(self.cache_ttl * 2, 86400))
736
752
 
753
+ async def _save_response_cache(self, response):
754
+ """Cache a pre-built JsonResponse's body.
755
+
756
+ Mirrors :meth:`save_cache` but skips JSON re-encoding — the
757
+ ``response.content`` is already JSON bytes. Used when a handler
758
+ returns ``JsonResponse(...)`` directly (a common pattern for
759
+ custom ``get_objs`` overrides) and ``serialize`` would otherwise
760
+ short-circuit before ``save_cache`` runs.
761
+ """
762
+ if not self.cache:
763
+ return
764
+ body = response.content
765
+ if isinstance(body, bytes):
766
+ body = body.decode('utf-8')
767
+ redis = get_redis()
768
+ await redis.setex(self.cache_key, self.cache_ttl, body)
769
+ if self.cache_namespace:
770
+ ns_key = f'{KEY_PREFIX}cache_ns:{self.cache_namespace}'
771
+ await redis.sadd(ns_key, self.cache_key)
772
+ await redis.expire(ns_key, max(self.cache_ttl * 2, 86400))
773
+
737
774
  async def invalidate_cache(self, namespaces):
738
775
  if not namespaces:
739
776
  return
@@ -0,0 +1,106 @@
1
+ import asyncio
2
+ import os
3
+ import weakref
4
+
5
+ import redis.asyncio as aioredis
6
+
7
+ REDIS_SERVER = os.environ['REDIS_SERVER']
8
+ REDIS_DB = int(os.environ['REDIS_DB'])
9
+
10
+ try:
11
+ from settings.env import REDIS_PREFIX
12
+ except ImportError:
13
+ REDIS_PREFIX = os.environ.get('REDIS_PREFIX', '')
14
+
15
+ KEY_PREFIX = f'{REDIS_PREFIX}:' if REDIS_PREFIX else ''
16
+
17
+
18
+ # `redis.asyncio.Redis` ties its connection futures to the event loop
19
+ # it was created on. ASGI servers + ``async_to_sync`` bridges spin up
20
+ # multiple loops in the same process — a single process-global client
21
+ # crashes those calls with ``RuntimeError: got Future attached to a
22
+ # different loop``. We cache one client per loop in a WeakKeyDictionary
23
+ # so entries auto-clean when the loop is GC'd.
24
+ _clients = weakref.WeakKeyDictionary()
25
+
26
+ # Fallback for callers without a running loop (rare; sync contexts that
27
+ # manually drive the event loop). Created lazily.
28
+ _loopless_client = None
29
+
30
+
31
+ # Tests monkeypatch this attribute to inject a fake (e.g. fakeredis).
32
+ # When set, ``get_redis()`` returns it unconditionally. None in
33
+ # production, where per-loop caching takes over.
34
+ _redis_client = None
35
+
36
+
37
+ def get_redis():
38
+ global _loopless_client
39
+
40
+ # Test injection hook
41
+ if _redis_client is not None:
42
+ return _redis_client
43
+
44
+ try:
45
+ loop = asyncio.get_running_loop()
46
+ except RuntimeError:
47
+ if _loopless_client is None:
48
+ _loopless_client = aioredis.Redis(
49
+ host=REDIS_SERVER,
50
+ db=REDIS_DB,
51
+ decode_responses=True,
52
+ )
53
+ return _loopless_client
54
+
55
+ client = _clients.get(loop)
56
+ if client is None:
57
+ client = aioredis.Redis(
58
+ host=REDIS_SERVER,
59
+ db=REDIS_DB,
60
+ decode_responses=True,
61
+ )
62
+ try:
63
+ _clients[loop] = client
64
+ except TypeError:
65
+ # Custom loop without weakref support — fall back to
66
+ # returning a fresh client without caching.
67
+ pass
68
+ return client
69
+
70
+
71
+ async def get_cache_stats():
72
+ redis = get_redis()
73
+ hits = int(await redis.get(f'{KEY_PREFIX}cache_stats:hits') or 0)
74
+ misses = int(await redis.get(f'{KEY_PREFIX}cache_stats:misses') or 0)
75
+ total = hits + misses
76
+ ratio = hits / total if total else 0.0
77
+
78
+ pattern = f'{KEY_PREFIX}cache_stats:hits:*'
79
+ by_model = {}
80
+ async for key in redis.scan_iter(match=pattern):
81
+ label = key.split(':hits:', 1)[1]
82
+ h = int(await redis.get(key) or 0)
83
+ m = int(await redis.get(f'{KEY_PREFIX}cache_stats:misses:{label}') or 0)
84
+ t = h + m
85
+ by_model[label] = {
86
+ 'hits': h,
87
+ 'misses': m,
88
+ 'total': t,
89
+ 'ratio': h / t if t else 0.0,
90
+ }
91
+
92
+ return {
93
+ 'hits': hits,
94
+ 'misses': misses,
95
+ 'total': total,
96
+ 'ratio': ratio,
97
+ 'by_model': by_model,
98
+ }
99
+
100
+
101
+ async def reset_cache_stats():
102
+ redis = get_redis()
103
+ pattern = f'{KEY_PREFIX}cache_stats:*'
104
+ keys = [k async for k in redis.scan_iter(match=pattern)]
105
+ if keys:
106
+ await redis.delete(*keys)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyapi_django
3
- Version: 0.32
3
+ Version: 0.34
4
4
  Summary: A simple rest api generator for django based on models
5
5
  Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
6
6
  License: MIT
@@ -278,12 +278,14 @@ class TaskResource(BaseResource):
278
278
  cache_scope_fields = ['space_id', ('account', 'plan_id')]
279
279
  ```
280
280
 
281
- Lenient by default: if the request has authenticated context but a
282
- configured field is **missing** from the session payload, the framework
283
- logs a `WARNING` (logger `easyapi.base`) and **skips the scope fold** —
284
- the request still serves, the cache may be shared across scopes until
285
- the operator updates the session payload. Anonymous requests also skip
286
- the fold. `None`, `0` and `''` count as present (a real value).
281
+ When a request has authenticated context but a configured scope field
282
+ is **missing** from the session payload, the framework logs a `WARNING`
283
+ (logger `easyapi.base`) and **disables cache for that request** — the
284
+ response is neither read from nor written to Redis. Sharing a key
285
+ across users when the scope can't be resolved would be a silent leak
286
+ across whatever dimension the operator was trying to protect.
287
+ Anonymous requests skip the fold cleanly (no warning, no leak).
288
+ `None`, `0` and `''` count as present (a real value).
287
289
 
288
290
  Use `before_cache` for the rare case that needs context outside
289
291
  `self.user` / `self.account`:
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
11
11
 
12
12
  [project]
13
13
  name = "easyapi_django"
14
- version = "0.32"
14
+ version = "0.34"
15
15
  authors = [
16
16
  { name="Stamatios Stamou Jr", email="bushier.outsets.0c@icloud.com" },
17
17
  ]
@@ -92,6 +92,50 @@ def test_cache_namespaces_no_model_returns_empty():
92
92
  assert res._cache_namespaces() == []
93
93
 
94
94
 
95
+ @pytest.mark.asyncio
96
+ async def test_save_response_cache_persists_jsonresponse_body(fake_redis):
97
+ """Resources whose handler returns ``JsonResponse(...)`` directly
98
+ bypass ``serialize()``. Without ``_save_response_cache`` the body
99
+ was never written to Redis even with ``cache = True`` — cache miss
100
+ forever (the ``/api/me`` symptom)."""
101
+ from django.http import JsonResponse
102
+
103
+ res = CachedResource()
104
+ res.cache = True
105
+ res.cache_key = f'{KEY_PREFIX}cache:/me'
106
+ res.cache_namespace = 'list:testapp.space'
107
+
108
+ payload = {'id': 7, 'name': 'demo', 'email': 'a@b.com'}
109
+ response = JsonResponse(payload)
110
+
111
+ await res._save_response_cache(response)
112
+
113
+ redis = get_redis()
114
+ raw = await redis.get(res.cache_key)
115
+ assert raw is not None
116
+
117
+ import json as _json
118
+ assert _json.loads(raw) == payload
119
+
120
+ members = await redis.smembers(f'{KEY_PREFIX}cache_ns:list:testapp.space')
121
+ assert res.cache_key in members
122
+
123
+
124
+ @pytest.mark.asyncio
125
+ async def test_save_response_cache_skipped_when_cache_off(fake_redis):
126
+ from django.http import JsonResponse
127
+
128
+ res = CachedResource()
129
+ res.cache = False
130
+ res.cache_key = f'{KEY_PREFIX}cache:/me'
131
+ res.cache_namespace = 'list:testapp.space'
132
+
133
+ await res._save_response_cache(JsonResponse({'id': 1}))
134
+
135
+ redis = get_redis()
136
+ assert await redis.get(res.cache_key) is None
137
+
138
+
95
139
  @pytest.mark.asyncio
96
140
  async def test_save_cache_handles_datetime(fake_redis):
97
141
  from datetime import datetime
@@ -157,43 +157,66 @@ def test_multiple_dimensions_combine():
157
157
  # ── lenient semantics (warn + skip fold, never 500) ────────────────
158
158
 
159
159
 
160
- def test_missing_field_logs_warning_and_skips_fold(caplog):
161
- """Missing scope field on authenticated request must not block the
162
- request — silent leak between scopes is preferable to taking the
163
- site down. Operators see a warning to fix the session payload."""
160
+ def test_missing_field_disables_cache(caplog):
161
+ """Missing scope field on authenticated request must disable cache
162
+ for that request — a shared key across users would silently leak
163
+ data across whatever dimension the operator was trying to protect.
164
+ The 500 strict mode was too aggressive (took the site down); just
165
+ skipping the fold and writing to a shared key was too permissive."""
164
166
  import logging as _logging
165
167
 
166
168
  class _R(_Resource):
167
169
  cache_scope_fields = ['space_id']
168
170
  res = _make(_R, user={'id': 1}) # space_id NOT in user
171
+ assert res.cache is True # configured
169
172
 
170
173
  with caplog.at_level(_logging.WARNING, logger='easyapi.base'):
171
174
  key = res._build_cache_key(_request(), None)
172
175
 
173
- assert 'scope=' not in key # fold skipped
176
+ # Fold skipped AND cache disabled for this request
177
+ assert 'scope=' not in key
178
+ assert res.cache is False, (
179
+ 'cache must be disabled when scope is configured but unresolved'
180
+ )
181
+
174
182
  records = [r for r in caplog.records if r.name == 'easyapi.base']
175
- assert records, 'expected a warning when scope field is missing'
183
+ assert records
176
184
  msg = records[0].getMessage()
177
185
  assert 'space_id' in msg
178
- assert '_R' in msg or 'class' in msg.lower()
186
+ assert 'disabling cache' in msg
179
187
 
180
188
 
181
- def test_missing_account_source_logs_warning_and_skips_fold(caplog):
189
+ def test_missing_account_source_disables_cache(caplog):
182
190
  import logging as _logging
183
191
 
184
192
  class _R(_Resource):
185
193
  cache_scope_fields = [('account', 'plan_id')]
186
194
  res = _make(_R, user={'id': 1}, account=None)
195
+ assert res.cache is True
187
196
 
188
197
  with caplog.at_level(_logging.WARNING, logger='easyapi.base'):
189
198
  key = res._build_cache_key(_request(), None)
190
199
 
191
200
  assert 'scope=' not in key
201
+ assert res.cache is False
192
202
  records = [r for r in caplog.records if r.name == 'easyapi.base']
193
203
  assert records
194
204
  assert 'account' in records[0].getMessage()
195
205
 
196
206
 
207
+ def test_resolved_scope_keeps_cache_enabled():
208
+ """Sanity: when scope resolves cleanly, cache stays enabled."""
209
+ class _R(_Resource):
210
+ cache_scope_fields = ['space_id']
211
+ res = _make(_R, user={'id': 1, 'space_id': 7})
212
+ assert res.cache is True
213
+
214
+ key = res._build_cache_key(_request(), None)
215
+
216
+ assert 'scope=' in key
217
+ assert res.cache is True
218
+
219
+
197
220
  # ── coexistence with session_cache ─────────────────────────────────
198
221
 
199
222
 
@@ -21,6 +21,41 @@ def test_get_redis_returns_singleton(fake_redis):
21
21
  assert a is b
22
22
 
23
23
 
24
+ def test_get_redis_caches_per_loop(monkeypatch):
25
+ """Without the test injection hook, get_redis must hand a separate
26
+ client to each event loop — otherwise asyncio raises 'Future
27
+ attached to a different loop' under ASGI + async_to_sync."""
28
+ import asyncio
29
+
30
+ monkeypatch.setattr(redis_config, '_redis_client', None)
31
+ redis_config._clients.clear()
32
+
33
+ async def grab():
34
+ return get_redis()
35
+
36
+ loop_a = asyncio.new_event_loop()
37
+ loop_b = asyncio.new_event_loop()
38
+ try:
39
+ client_a = loop_a.run_until_complete(grab())
40
+ client_b = loop_b.run_until_complete(grab())
41
+ # Same loop reused → same client; different loops → different clients.
42
+ client_a_again = loop_a.run_until_complete(grab())
43
+ assert client_a is client_a_again
44
+ assert client_a is not client_b
45
+ finally:
46
+ loop_a.close()
47
+ loop_b.close()
48
+
49
+
50
+ def test_get_redis_test_hook_overrides_per_loop_cache(fake_redis):
51
+ """The conftest fixture monkeypatches ``_redis_client``; that hook
52
+ must short-circuit the per-loop logic so tests get the fakeredis
53
+ instance regardless of which loop they run in."""
54
+ a = get_redis()
55
+ b = get_redis()
56
+ assert a is b is fake_redis
57
+
58
+
24
59
  @pytest.mark.asyncio
25
60
  async def test_cache_stats_empty(fake_redis):
26
61
  await reset_cache_stats()
@@ -1,64 +0,0 @@
1
- import os
2
-
3
- import redis.asyncio as aioredis
4
-
5
- REDIS_SERVER = os.environ['REDIS_SERVER']
6
- REDIS_DB = int(os.environ['REDIS_DB'])
7
-
8
- try:
9
- from settings.env import REDIS_PREFIX
10
- except ImportError:
11
- REDIS_PREFIX = os.environ.get('REDIS_PREFIX', '')
12
-
13
- KEY_PREFIX = f'{REDIS_PREFIX}:' if REDIS_PREFIX else ''
14
-
15
- _redis_client = None
16
-
17
-
18
- def get_redis():
19
- global _redis_client
20
- if _redis_client is None:
21
- _redis_client = aioredis.Redis(
22
- host=REDIS_SERVER,
23
- db=REDIS_DB,
24
- decode_responses=True,
25
- )
26
- return _redis_client
27
-
28
-
29
- async def get_cache_stats():
30
- redis = get_redis()
31
- hits = int(await redis.get(f'{KEY_PREFIX}cache_stats:hits') or 0)
32
- misses = int(await redis.get(f'{KEY_PREFIX}cache_stats:misses') or 0)
33
- total = hits + misses
34
- ratio = hits / total if total else 0.0
35
-
36
- pattern = f'{KEY_PREFIX}cache_stats:hits:*'
37
- by_model = {}
38
- async for key in redis.scan_iter(match=pattern):
39
- label = key.split(':hits:', 1)[1]
40
- h = int(await redis.get(key) or 0)
41
- m = int(await redis.get(f'{KEY_PREFIX}cache_stats:misses:{label}') or 0)
42
- t = h + m
43
- by_model[label] = {
44
- 'hits': h,
45
- 'misses': m,
46
- 'total': t,
47
- 'ratio': h / t if t else 0.0,
48
- }
49
-
50
- return {
51
- 'hits': hits,
52
- 'misses': misses,
53
- 'total': total,
54
- 'ratio': ratio,
55
- 'by_model': by_model,
56
- }
57
-
58
-
59
- async def reset_cache_stats():
60
- redis = get_redis()
61
- pattern = f'{KEY_PREFIX}cache_stats:*'
62
- keys = [k async for k in redis.scan_iter(match=pattern)]
63
- if keys:
64
- await redis.delete(*keys)
File without changes
File without changes