easyapi-django 0.34__tar.gz → 0.35__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.
- {easyapi_django-0.34/easyapi_django.egg-info → easyapi_django-0.35}/PKG-INFO +1 -1
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/base.py +9 -2
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/redis_config.py +49 -18
- {easyapi_django-0.34 → easyapi_django-0.35/easyapi_django.egg-info}/PKG-INFO +1 -1
- {easyapi_django-0.34 → easyapi_django-0.35}/pyproject.toml +1 -1
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_redis_config.py +25 -3
- {easyapi_django-0.34 → easyapi_django-0.35}/LICENSE +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/README.md +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/auth.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/blocking.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/calc.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/calc_resource.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/client_ip.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/constants.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/dates.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/exception.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/filters.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/helpers.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/management/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/mcp/bridge.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/mcp/resource.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/middleware.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/openapi.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/rate_limit.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/routes.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/schemas.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/security.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/serializer.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/tenant/db_router.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/tenant/tenant.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/util.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi/ws.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi_django.egg-info/SOURCES.txt +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/setup.cfg +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_api_key_resolver.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_auth.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_base_init.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_block.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_cache.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_cache_auto_account_scope.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_cache_scope.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_client_ip.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_dispatch_error_handling.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_exception_render.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_exports.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_filter_validation.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_filters.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_get_obj_m2m_only.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_helpers.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_index_route.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_mcp.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_mcp_middleware_chain.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_normalize_field.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_openapi.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_owner_field.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_return_result_falsy.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_routes_gate.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_schemas.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_security.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_serializer.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_tenant_connection.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_tenant_registry.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_util.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_ws_channels.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.35}/tests/test_ws_optional.py +0 -0
|
@@ -482,8 +482,15 @@ class BaseResource(View):
|
|
|
482
482
|
|
|
483
483
|
label = self.model._meta.label_lower if self.model else 'unknown'
|
|
484
484
|
outcome = 'hits' if response else 'misses'
|
|
485
|
-
|
|
486
|
-
|
|
485
|
+
# Per-model counters live in a single hash so ``get_cache_stats``
|
|
486
|
+
# can read them with one HGETALL instead of SCAN over the whole
|
|
487
|
+
# keyspace (the original ``cache_stats:hits:<label>`` layout
|
|
488
|
+
# cost 10+ seconds on production-sized Redis).
|
|
489
|
+
from .redis_config import _BY_MODEL_KEY, cache_stats_field
|
|
490
|
+
pipe = redis.pipeline()
|
|
491
|
+
pipe.incr(f'{KEY_PREFIX}cache_stats:{outcome}')
|
|
492
|
+
pipe.hincrby(_BY_MODEL_KEY, cache_stats_field(outcome, label), 1)
|
|
493
|
+
await pipe.execute()
|
|
487
494
|
|
|
488
495
|
if response:
|
|
489
496
|
return HttpResponse(response, content_type='application/json')
|
|
@@ -68,26 +68,56 @@ def get_redis():
|
|
|
68
68
|
return client
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
# Cache hit/miss counters live in two places now:
|
|
72
|
+
#
|
|
73
|
+
# {KEY_PREFIX}cache_stats:hits global hit counter (INCR)
|
|
74
|
+
# {KEY_PREFIX}cache_stats:misses global miss counter (INCR)
|
|
75
|
+
# {KEY_PREFIX}cache_stats:by_model HASH keyed by ``hits:<label>`` /
|
|
76
|
+
# ``misses:<label>`` (HINCRBY)
|
|
77
|
+
#
|
|
78
|
+
# Pre-0.30 deploys used per-model keys (``cache_stats:hits:<label>``) and
|
|
79
|
+
# discovered them via ``SCAN``. With a large keyspace SCAN walked every
|
|
80
|
+
# key in the database, costing 10+ seconds for a single ``cachestats``
|
|
81
|
+
# call. The HASH variant reads everything in one ``HGETALL`` (O(M) where
|
|
82
|
+
# M = number of distinct models). Counters reset across the upgrade —
|
|
83
|
+
# they were observability-only.
|
|
84
|
+
_BY_MODEL_KEY = f'{KEY_PREFIX}cache_stats:by_model'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cache_stats_field(outcome, label):
|
|
88
|
+
"""Hash field for ``cache_stats:by_model``. ``outcome`` is ``hits`` or
|
|
89
|
+
``misses``; ``label`` is the model's ``app.model`` lowercased name."""
|
|
90
|
+
return f'{outcome}:{label}'
|
|
91
|
+
|
|
92
|
+
|
|
71
93
|
async def get_cache_stats():
|
|
72
94
|
redis = get_redis()
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
pipe = redis.pipeline()
|
|
96
|
+
pipe.get(f'{KEY_PREFIX}cache_stats:hits')
|
|
97
|
+
pipe.get(f'{KEY_PREFIX}cache_stats:misses')
|
|
98
|
+
pipe.hgetall(_BY_MODEL_KEY)
|
|
99
|
+
raw_hits, raw_misses, raw_by_model = await pipe.execute()
|
|
100
|
+
|
|
101
|
+
hits = int(raw_hits or 0)
|
|
102
|
+
misses = int(raw_misses or 0)
|
|
75
103
|
total = hits + misses
|
|
76
104
|
ratio = hits / total if total else 0.0
|
|
77
105
|
|
|
78
|
-
pattern = f'{KEY_PREFIX}cache_stats:hits:*'
|
|
79
106
|
by_model = {}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
for field, value in (raw_by_model or {}).items():
|
|
108
|
+
if ':' not in field:
|
|
109
|
+
continue
|
|
110
|
+
kind, label = field.split(':', 1)
|
|
111
|
+
if kind not in ('hits', 'misses'):
|
|
112
|
+
continue
|
|
113
|
+
bucket = by_model.setdefault(label, {'hits': 0, 'misses': 0})
|
|
114
|
+
bucket[kind] = int(value)
|
|
115
|
+
|
|
116
|
+
for label, bucket in by_model.items():
|
|
117
|
+
bucket['total'] = bucket['hits'] + bucket['misses']
|
|
118
|
+
bucket['ratio'] = (
|
|
119
|
+
bucket['hits'] / bucket['total'] if bucket['total'] else 0.0
|
|
120
|
+
)
|
|
91
121
|
|
|
92
122
|
return {
|
|
93
123
|
'hits': hits,
|
|
@@ -100,7 +130,8 @@ async def get_cache_stats():
|
|
|
100
130
|
|
|
101
131
|
async def reset_cache_stats():
|
|
102
132
|
redis = get_redis()
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
133
|
+
pipe = redis.pipeline()
|
|
134
|
+
pipe.delete(f'{KEY_PREFIX}cache_stats:hits')
|
|
135
|
+
pipe.delete(f'{KEY_PREFIX}cache_stats:misses')
|
|
136
|
+
pipe.delete(_BY_MODEL_KEY)
|
|
137
|
+
await pipe.execute()
|
|
@@ -77,8 +77,12 @@ async def test_cache_stats_counts(fake_redis):
|
|
|
77
77
|
await redis.incr(f'{KEY_PREFIX}cache_stats:hits')
|
|
78
78
|
await redis.incr(f'{KEY_PREFIX}cache_stats:hits')
|
|
79
79
|
await redis.incr(f'{KEY_PREFIX}cache_stats:misses')
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
# Per-model counters live in a single hash now (HGETALL on read,
|
|
81
|
+
# HINCRBY on write) so ``get_cache_stats`` doesn't SCAN the whole
|
|
82
|
+
# keyspace when Redis has millions of unrelated keys.
|
|
83
|
+
by_model_key = f'{KEY_PREFIX}cache_stats:by_model'
|
|
84
|
+
await redis.hincrby(by_model_key, 'hits:testapp.space', 1)
|
|
85
|
+
await redis.hincrby(by_model_key, 'misses:testapp.space', 1)
|
|
82
86
|
|
|
83
87
|
stats = await get_cache_stats()
|
|
84
88
|
assert stats['hits'] == 3
|
|
@@ -93,11 +97,29 @@ async def test_cache_stats_counts(fake_redis):
|
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_cache_stats_handles_only_misses(fake_redis):
|
|
102
|
+
"""Resource that always misses should still appear with hits=0."""
|
|
103
|
+
await reset_cache_stats()
|
|
104
|
+
redis = get_redis()
|
|
105
|
+
by_model_key = f'{KEY_PREFIX}cache_stats:by_model'
|
|
106
|
+
await redis.hincrby(by_model_key, 'misses:testapp.space', 5)
|
|
107
|
+
|
|
108
|
+
stats = await get_cache_stats()
|
|
109
|
+
assert stats['by_model']['testapp.space'] == {
|
|
110
|
+
'hits': 0,
|
|
111
|
+
'misses': 5,
|
|
112
|
+
'total': 5,
|
|
113
|
+
'ratio': 0.0,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
96
117
|
@pytest.mark.asyncio
|
|
97
118
|
async def test_reset_cache_stats(fake_redis):
|
|
98
119
|
redis = get_redis()
|
|
120
|
+
by_model_key = f'{KEY_PREFIX}cache_stats:by_model'
|
|
99
121
|
await redis.incr(f'{KEY_PREFIX}cache_stats:hits')
|
|
100
|
-
await redis.
|
|
122
|
+
await redis.hincrby(by_model_key, 'misses:testapp.space', 3)
|
|
101
123
|
|
|
102
124
|
await reset_cache_stats()
|
|
103
125
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|