quadkit-cache 0.0.44__tar.gz → 0.0.45__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 (196) hide show
  1. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/PKG-INFO +1 -1
  2. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/pyproject.toml +1 -1
  3. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memory/backend.py +16 -3
  4. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memory/kv.py +1 -1
  5. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memory_state.py +57 -0
  6. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/cli/templates/cache_repository.py.jinja2 +5 -1
  7. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/di/_registration.py +1 -1
  8. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/core.py +50 -1
  9. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/stores/redis_state.py +26 -0
  10. quadkit_cache-0.0.45/tests/unit/service/test_reporting_surface.py +136 -0
  11. quadkit_cache-0.0.45/tests/unit/test_state_store_protocol_conformance.py +196 -0
  12. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/.gitignore +0 -0
  13. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/LICENSE +0 -0
  14. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/NOTICE +0 -0
  15. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/README.md +0 -0
  16. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/example.yaml +0 -0
  17. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/__init__.py +0 -0
  18. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/__init__.py +0 -0
  19. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/contributor.py +0 -0
  20. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/handlers/__init__.py +0 -0
  21. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/handlers/backend_ping.py +0 -0
  22. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/handlers/eviction_rate.py +0 -0
  23. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/handlers/hit_miss_ratio.py +0 -0
  24. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/pages/__init__.py +0 -0
  25. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/pages/keys.py +0 -0
  26. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/pages/overview.py +0 -0
  27. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/admin/pages/stats.py +0 -0
  28. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/__init__.py +0 -0
  29. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/factory.py +0 -0
  30. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/hash.py +0 -0
  31. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memcached/__init__.py +0 -0
  32. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memcached/backend.py +0 -0
  33. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memcached/py.typed +0 -0
  34. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memory/__init__.py +0 -0
  35. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memory/py.typed +0 -0
  36. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memory_lock.py +0 -0
  37. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/memory_secrets.py +0 -0
  38. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/py.typed +0 -0
  39. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/redis/__init__.py +0 -0
  40. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/redis/backend.py +0 -0
  41. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/redis/circuit.py +0 -0
  42. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/redis/py.typed +0 -0
  43. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/redis/resilience.py +0 -0
  44. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/redis/resilient.py +0 -0
  45. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/backends/registry.py +0 -0
  46. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/cli/__init__.py +0 -0
  47. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/cli/checks.py +0 -0
  48. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/cli/commands.py +0 -0
  49. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/cli/contributor.py +0 -0
  50. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/cli/doctor.py +0 -0
  51. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/cli/generators/__init__.py +0 -0
  52. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/cli/generators/cache_repository.py +0 -0
  53. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/cli/shell.py +0 -0
  54. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/config/__init__.py +0 -0
  55. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/config/backends.py +0 -0
  56. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/config/loaders.py +0 -0
  57. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/config/operation.py +0 -0
  58. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/config/service.py +0 -0
  59. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/config/top_level.py +0 -0
  60. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/constants.py +0 -0
  61. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/decorators.py +0 -0
  62. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/di/__init__.py +0 -0
  63. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/di/_services.py +0 -0
  64. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/di/provider.py +0 -0
  65. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/di/py.typed +0 -0
  66. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/events.py +0 -0
  67. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/exceptions.py +0 -0
  68. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/hooks.py +0 -0
  69. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/locks/__init__.py +0 -0
  70. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/locks/auto_renewing.py +0 -0
  71. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/locks/context.py +0 -0
  72. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/locks/distributed.py +0 -0
  73. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/locks/manager.py +0 -0
  74. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/module.py +0 -0
  75. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/protocols.py +0 -0
  76. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/py.typed +0 -0
  77. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/repository/__init__.py +0 -0
  78. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/repository/base.py +0 -0
  79. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/repository/collections.py +0 -0
  80. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/repository/py.typed +0 -0
  81. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/semantic/__init__.py +0 -0
  82. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/semantic/cost_decision.py +0 -0
  83. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/semantic/protocols.py +0 -0
  84. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/semantic/store.py +0 -0
  85. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/semantic/vector_index.py +0 -0
  86. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/serialization/__init__.py +0 -0
  87. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/serialization/base.py +0 -0
  88. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/serialization/compression.py +0 -0
  89. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/serialization/factory.py +0 -0
  90. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/serialization/json.py +0 -0
  91. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/serialization/msgpack.py +0 -0
  92. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/serialization/py.typed +0 -0
  93. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/serialization/type_registry.py +0 -0
  94. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/__init__.py +0 -0
  95. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/decorators.py +0 -0
  96. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/factory.py +0 -0
  97. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/health.py +0 -0
  98. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/invalidation.py +0 -0
  99. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/patterns.py +0 -0
  100. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/pipeline.py +0 -0
  101. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/protection.py +0 -0
  102. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/py.typed +0 -0
  103. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/registry.py +0 -0
  104. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/request_cache.py +0 -0
  105. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/stampede.py +0 -0
  106. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/service/warmer.py +0 -0
  107. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/stores/__init__.py +0 -0
  108. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/stores/redis_lock.py +0 -0
  109. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/stores/redis_secrets.py +0 -0
  110. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/strategies/py.typed +0 -0
  111. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/strategies/tiered.py +0 -0
  112. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/strategies/write_through.py +0 -0
  113. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/cache/types.py +0 -0
  114. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/src/quadkit/py.typed +0 -0
  115. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/conftest.py +0 -0
  116. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/integration/__init__.py +0 -0
  117. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/integration/conftest.py +0 -0
  118. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/integration/test_cache_lifecycle.py +0 -0
  119. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/integration/test_redis_cache_compliance.py +0 -0
  120. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/integration/test_redis_provider_lifecycle.py +0 -0
  121. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/abstractions/test_cache_backend.py +0 -0
  122. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/admin/__init__.py +0 -0
  123. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/admin/handlers/__init__.py +0 -0
  124. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/admin/handlers/test_backend_ping.py +0 -0
  125. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/admin/handlers/test_eviction_rate.py +0 -0
  126. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/admin/handlers/test_hit_miss_ratio.py +0 -0
  127. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/admin/test_cache_contributor.py +0 -0
  128. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_backend_error_logging.py +0 -0
  129. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_backend_registry_extra.py +0 -0
  130. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_memcached.py +0 -0
  131. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_memcached_client_precedence.py +0 -0
  132. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_memory.py +0 -0
  133. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_memory_eviction.py +0 -0
  134. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_memory_kv.py +0 -0
  135. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_memory_lock.py +0 -0
  136. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_memory_secrets.py +0 -0
  137. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_memory_stats.py +0 -0
  138. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_redis.py +0 -0
  139. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/backends/test_resilient_redis.py +0 -0
  140. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/cli/test_generators_contract.py +0 -0
  141. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/conftest.py +0 -0
  142. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/integration/test_backends_create.py +0 -0
  143. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/integration/test_config.py +0 -0
  144. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/integration/test_integration_helpers.py +0 -0
  145. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/integration/test_provider_quadkit_cache_integration.py +0 -0
  146. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/semantic/__init__.py +0 -0
  147. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/semantic/test_semantic_cache_eviction.py +0 -0
  148. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/semantic/test_semantic_cache_lookup.py +0 -0
  149. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/semantic/test_semantic_cache_store.py +0 -0
  150. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/semantic/test_semantic_cache_vector_index.py +0 -0
  151. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/serialization/test_base.py +0 -0
  152. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/serialization/test_serialization.py +0 -0
  153. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/service/test_cache_service_get_typed.py +0 -0
  154. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/service/test_decorators.py +0 -0
  155. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/service/test_invalidation.py +0 -0
  156. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/service/test_service.py +0 -0
  157. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/service/test_stampede_protection.py +0 -0
  158. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/stores/test_redis_lock_extra.py +0 -0
  159. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/stores/test_redis_secrets_extra.py +0 -0
  160. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/stores/test_redis_state_extra.py +0 -0
  161. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_admin_stats_and_events.py +0 -0
  162. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_admin_protocol.py +0 -0
  163. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_cli.py +0 -0
  164. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_config.py +0 -0
  165. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_config_loaders.py +0 -0
  166. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_config_top_level.py +0 -0
  167. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_constants.py +0 -0
  168. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_di_registration.py +0 -0
  169. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_exceptions.py +0 -0
  170. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_module.py +0 -0
  171. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_protocols.py +0 -0
  172. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_provider.py +0 -0
  173. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_provider_named.py +0 -0
  174. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_providers.py +0 -0
  175. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_result_pattern.py +0 -0
  176. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_stores.py +0 -0
  177. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_strategies.py +0 -0
  178. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_types.py +0 -0
  179. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cache_warmer.py +0 -0
  180. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_cli_contributor.py +0 -0
  181. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_compressing_serializer.py +0 -0
  182. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_config_loading.py +0 -0
  183. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_di_boundary_contract.py +0 -0
  184. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_distributed_lock.py +0 -0
  185. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_exceptions.py +0 -0
  186. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_exceptions_quadkit_cache.py +0 -0
  187. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_lock_renewal.py +0 -0
  188. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_locks.py +0 -0
  189. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_package_hooks_p2.py +0 -0
  190. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_redis_fake.py +0 -0
  191. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_redis_resilience_backends.py +0 -0
  192. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_repository.py +0 -0
  193. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_request_cache.py +0 -0
  194. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_round28_hardening.py +0 -0
  195. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_stampede_coalescing.py +0 -0
  196. {quadkit_cache-0.0.44 → quadkit_cache-0.0.45}/tests/unit/test_type_registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quadkit-cache
3
- Version: 0.0.44
3
+ Version: 0.0.45
4
4
  Summary: Multi-backend caching system for Quadkit Framework - Redis, Memcached, and in-memory caching
5
5
  Project-URL: Homepage, https://dbtinoy-.github.io/quadkit
6
6
  Project-URL: Repository, https://github.com/dbtinoy-/quadkit
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "quadkit-cache"
9
- version = "0.0.44"
9
+ version = "0.0.45"
10
10
  description = "Multi-backend caching system for Quadkit Framework - Redis, Memcached, and in-memory caching"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.11"
@@ -54,7 +54,8 @@ class MemoryCacheBackend(CacheBackendProtocol):
54
54
  hooks: Optional hook registry for lifecycle emission.
55
55
  """
56
56
  self.config = config or default_cache_config()
57
- self._store = MemoryStateStore(max_size=max_size) # type: ignore[abstract]
57
+ self.max_size = max_size
58
+ self._store = MemoryStateStore(max_size=max_size)
58
59
  self._metrics = CacheMetrics()
59
60
  self._evictions = 0
60
61
  self._hooks = hooks
@@ -317,17 +318,29 @@ class MemoryCacheBackend(CacheBackendProtocol):
317
318
  Dict with ``hits``, ``misses``, ``evictions``, and ``entries``
318
319
  counts, or None when statistics are unavailable.
319
320
  """
321
+ store_stats = getattr(self._store, "get_stats", None)
322
+ store_evictions = 0
323
+ store_entries = len(self._store._data)
324
+ if callable(store_stats):
325
+ reported = store_stats() or {}
326
+ store_evictions = int(reported.get("evictions", 0) or 0)
327
+ store_entries = int(reported.get("entries", store_entries) or 0)
320
328
  return {
321
329
  "hits": self._metrics.hits,
322
330
  "misses": self._metrics.misses,
323
- "evictions": self._evictions,
324
- "entries": len(self._store._data),
331
+ # Both the backend's own eviction paths (tag/LRU maintenance) and
332
+ # the store's capacity evictions.
333
+ "evictions": self._evictions + store_evictions,
334
+ "entries": store_entries,
325
335
  }
326
336
 
327
337
  def reset_stats(self) -> None:
328
338
  """Reset counters without evicting cached entries."""
329
339
  self._metrics.reset()
330
340
  self._evictions = 0
341
+ store = self._store
342
+ if hasattr(store, "eviction_count"):
343
+ store.eviction_count = 0
331
344
 
332
345
  async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
333
346
  """
@@ -43,7 +43,7 @@ class InMemoryStorage(StorageBackendProtocol):
43
43
  def _get_store(self, namespace: str) -> MemoryStateStore:
44
44
  """Return (or create) the MemoryStateStore for *namespace*."""
45
45
  if namespace not in self._stores:
46
- self._stores[namespace] = MemoryStateStore( # type: ignore[abstract]
46
+ self._stores[namespace] = MemoryStateStore(
47
47
  max_size=self.max_size,
48
48
  )
49
49
  return self._stores[namespace]
@@ -31,6 +31,10 @@ class MemoryStateStore(StateStoreProtocol):
31
31
  entry is evicted first. ``None`` (default) disables eviction.
32
32
  """
33
33
  self.max_size = max_size
34
+ #: Entries dropped to make room for new ones. Expired entries that
35
+ #: happen to be reaped during a write are not evictions — they were
36
+ #: going to disappear anyway — so only the LRU drop is counted here.
37
+ self.eviction_count = 0
34
38
  # OrderedDict preserves insertion/access order for LRU tracking.
35
39
  self._data: OrderedDict[str, dict[str, Any]] = OrderedDict()
36
40
 
@@ -98,6 +102,7 @@ class MemoryStateStore(StateStoreProtocol):
98
102
  self._evict_expired()
99
103
  if len(self._data) >= self.max_size:
100
104
  self._data.popitem(last=False) # remove least-recently used
105
+ self.eviction_count += 1
101
106
 
102
107
  self._data[key] = {"value": serialized_value, "expires_at": expires_at}
103
108
 
@@ -120,6 +125,44 @@ class MemoryStateStore(StateStoreProtocol):
120
125
  return False
121
126
  return True
122
127
 
128
+ async def expire(self, key: str, ttl: int) -> bool:
129
+ """Set a new expiration on an existing, non-expired key."""
130
+ entry = self._data.get(key)
131
+ if entry is None:
132
+ return False
133
+ now = ambient_clock.timestamp()
134
+ expires_at = entry.get("expires_at")
135
+ if expires_at is not None and now > expires_at:
136
+ del self._data[key]
137
+ return False
138
+ entry["expires_at"] = now + ttl
139
+ if self.max_size is not None:
140
+ self._data.move_to_end(key)
141
+ return True
142
+
143
+ async def ttl(self, key: str) -> int:
144
+ """Remaining TTL in seconds; -1 with no expiry, -2 when absent."""
145
+ entry = self._data.get(key)
146
+ if entry is None:
147
+ return -2
148
+ now = ambient_clock.timestamp()
149
+ expires_at = entry.get("expires_at")
150
+ if expires_at is None:
151
+ return -1
152
+ if now > expires_at:
153
+ del self._data[key]
154
+ return -2
155
+ return max(0, int(expires_at - now))
156
+
157
+ async def get_many(self, keys: list[str]) -> dict[str, Any]:
158
+ """Fetch several keys at once; absent keys are omitted."""
159
+ return await self.get_bulk(keys)
160
+
161
+ async def set_many(self, items: dict[str, Any], ttl: int | None = None) -> None:
162
+ """Persist several key-value pairs in one call."""
163
+ for key, value in items.items():
164
+ await self.set(key, value, ttl)
165
+
123
166
  async def clear(self) -> None:
124
167
  """Clear all values from the store."""
125
168
  self._data.clear()
@@ -133,6 +176,19 @@ class MemoryStateStore(StateStoreProtocol):
133
176
  result[key] = value
134
177
  return result
135
178
 
179
+ def get_stats(self) -> dict[str, Any]:
180
+ """Return occupancy and eviction counts for administrative reporting.
181
+
182
+ Reporting lives here rather than in each backend so a backend without
183
+ an eviction counter of its own (the memory cache) still reports the
184
+ evictions its store performed.
185
+ """
186
+ return {
187
+ "entries": len(self._data),
188
+ "max_size": self.max_size,
189
+ "evictions": self.eviction_count,
190
+ }
191
+
136
192
  async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
137
193
  """Check the health of the state store."""
138
194
  return HealthCheckResult(
@@ -142,5 +198,6 @@ class MemoryStateStore(StateStoreProtocol):
142
198
  "driver": "memory",
143
199
  "keys_count": len(self._data),
144
200
  "max_size": self.max_size,
201
+ "evictions": self.eviction_count,
145
202
  },
146
203
  )
@@ -1,4 +1,8 @@
1
- """{{ entity_name }} repository with caching.
1
+ """Convention followed: **Repository pattern** — a cache-aside
2
+ decorator: reads check the cache first, writes invalidate; the
3
+ wrapped repository stays the source of truth.
4
+
5
+ {{ entity_name }} repository with caching.
2
6
 
3
7
  This module provides a cached repository that wraps an underlying data
4
8
  repository and applies the cache-aside pattern:
@@ -136,7 +136,7 @@ class _CacheRegistrationMixin:
136
136
  and resolve_backend_type(backend_config) == BackendType.REDIS
137
137
  and backend_config.redis_url
138
138
  ):
139
- store = RedisStateStore( # type: ignore[abstract]
139
+ store = RedisStateStore(
140
140
  url=backend_config.redis_url,
141
141
  prefix=backend_config.key_prefix,
142
142
  )
@@ -84,7 +84,14 @@ class CacheService(PipelineMixin, InvalidationMixin, PatternsMixin):
84
84
  """
85
85
  self.provider = provider
86
86
  self._backend = backend
87
- self.config = config or default_cache_config()
87
+ # A directly-supplied backend carries the configuration that governs
88
+ # key prefixing and default TTL. Adopting it when the caller passed no
89
+ # service config keeps the two in step: the alternative is a service
90
+ # reporting no prefix while every key it writes is prefixed by the
91
+ # backend it delegates to.
92
+ self.config = (
93
+ config or getattr(backend, "config", None) or default_cache_config()
94
+ )
88
95
  self._protection = protection
89
96
  self._ctx = ctx
90
97
 
@@ -433,6 +440,48 @@ class CacheService(PipelineMixin, InvalidationMixin, PatternsMixin):
433
440
  """
434
441
  return self._metrics.copy()
435
442
 
443
+ @property
444
+ def backend(self) -> CacheBackendProtocol | None:
445
+ """Return the backing store, or ``None`` when only a provider is set.
446
+
447
+ Admin surfaces report per-cache occupancy and eviction from the
448
+ backend itself; exposing it keeps that reporting out of the private
449
+ attribute layout.
450
+ """
451
+ return self._backend
452
+
453
+ @property
454
+ def key_prefix(self) -> str:
455
+ """Return this service's key prefix (``""`` when it has none)."""
456
+ return str(getattr(self.config, "key_prefix", "") or "")
457
+
458
+ @property
459
+ def default_ttl(self) -> int | None:
460
+ """Return the default TTL in seconds applied when ``set`` omits one."""
461
+ value = getattr(self.config, "default_ttl", None)
462
+ return int(value) if value is not None else None
463
+
464
+ def get_backend_stats(self) -> dict[str, int]:
465
+ """Return the backend's own counters for administrative reporting.
466
+
467
+ ``get_stats`` is optional on the backend protocol — a Redis backend
468
+ answers through ``INFO`` instead — so an unreporting backend yields
469
+ zeros rather than fabricated activity.
470
+
471
+ Returns:
472
+ Mapping with ``hits``, ``misses``, ``evictions`` and ``entries``.
473
+ """
474
+ empty = {"hits": 0, "misses": 0, "evictions": 0, "entries": 0}
475
+ get_stats = getattr(self._backend, "get_stats", None)
476
+ if not callable(get_stats):
477
+ return empty
478
+ try:
479
+ raw = get_stats() or {}
480
+ except Exception: # noqa: BLE001 — reporting never raises
481
+ logger.warning("cache.backend_stats_unavailable", exc_info=True)
482
+ return empty
483
+ return {key: int(raw.get(key, 0) or 0) for key in empty}
484
+
436
485
  def reset_metrics(self) -> None:
437
486
  """Reset performance metrics."""
438
487
  self._metrics = {"hits": 0, "misses": 0, "errors": 0, "operations": 0}
@@ -151,6 +151,32 @@ class RedisStateStore(RedisDriver, StateStoreProtocol):
151
151
  await cast("Awaitable[Any]", client.delete(self._key(key)))
152
152
  return True
153
153
 
154
+ async def exists(self, key: str) -> bool:
155
+ """Check whether a key exists."""
156
+ client = await self._get_client()
157
+ return bool(await client.exists(self._key(key)))
158
+
159
+ async def expire(self, key: str, ttl: int) -> bool:
160
+ """Set expiration on a key; False when the key does not exist."""
161
+ client = await self._get_client()
162
+ return bool(await cast("Awaitable[Any]", client.expire(self._key(key), ttl)))
163
+
164
+ async def ttl(self, key: str) -> int:
165
+ """Remaining TTL in seconds; -1 with no expiry, -2 when absent."""
166
+ client = await self._get_client()
167
+ # Redis already returns -1 for "no expiry" and -2 for "no such key",
168
+ # which is exactly the StateStoreProtocol contract.
169
+ return int(await client.ttl(self._key(key)))
170
+
171
+ async def get_many(self, keys: list[str]) -> dict[str, Any]:
172
+ """Fetch several keys at once; absent keys are omitted."""
173
+ return await self.get_bulk(keys)
174
+
175
+ async def set_many(self, items: dict[str, Any], ttl: int | None = None) -> None:
176
+ """Persist several key-value pairs in one call."""
177
+ for key, value in items.items():
178
+ await self.set(key, value, ttl)
179
+
154
180
  async def get_bulk(self, keys: list[str]) -> dict[str, Any]:
155
181
  """Get multiple values by keys"""
156
182
  client = await self._get_client()
@@ -0,0 +1,136 @@
1
+ """The reporting surface admin dashboards read from a cache service.
2
+
3
+ Applications ship an admin cache dashboard; it needs the service's own
4
+ counters, the backend's counters (hits/misses/evictions/entries), the key
5
+ prefix and the default TTL. Asking for those through private attributes is
6
+ what this surface exists to avoid — and the eviction count in particular used
7
+ to be invisible: the store dropped the least-recently-used entry while the
8
+ backend reported ``evictions: 0``, so a full cache looked idle.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pytest
14
+
15
+ from quadkit.cache.backends.memory.backend import MemoryCacheBackend
16
+ from quadkit.cache.config.operation import CacheOperationConfig
17
+ from quadkit.cache.service.core import CacheService
18
+
19
+
20
+ def _service(**kwargs: object) -> CacheService:
21
+ config = CacheOperationConfig(default_ttl=60, key_prefix="gw:test:")
22
+ return CacheService(
23
+ backend=MemoryCacheBackend(config=config, max_size=3), **kwargs
24
+ )
25
+
26
+
27
+ def test_service_exposes_backend_prefix_and_ttl() -> None:
28
+ """Reporting reads the configuration through public properties."""
29
+ service = _service()
30
+ assert isinstance(service.backend, MemoryCacheBackend)
31
+ assert service.key_prefix == "gw:test:"
32
+ assert service.default_ttl == 60
33
+
34
+
35
+ def test_service_adopts_the_backend_config_when_none_is_given() -> None:
36
+ """A backend-injected service reports the configuration it delegates to."""
37
+ config = CacheOperationConfig(default_ttl=15, key_prefix="gw:x:")
38
+ service = CacheService(backend=MemoryCacheBackend(config=config))
39
+ assert service.key_prefix == "gw:x:"
40
+ assert service.default_ttl == 15
41
+
42
+
43
+ def test_explicit_service_config_wins() -> None:
44
+ """A caller-supplied service config is not overwritten by the backend's."""
45
+ config = CacheOperationConfig(default_ttl=15, key_prefix="gw:x:")
46
+ service = CacheService(
47
+ config=CacheOperationConfig(default_ttl=5, key_prefix="gw:y:"),
48
+ backend=MemoryCacheBackend(config=config),
49
+ )
50
+ assert service.key_prefix == "gw:y:"
51
+ assert service.default_ttl == 5
52
+
53
+
54
+ def test_backend_stats_are_counted_from_real_operations() -> None:
55
+ """Hits, misses and entries come from the operations that happened."""
56
+ service = _service()
57
+
58
+ async def _exercise() -> None:
59
+ await service.set("hit", "value")
60
+ assert await service.get("hit") == "value"
61
+ assert await service.get("absent") is None
62
+
63
+ asyncio_run(_exercise())
64
+ stats = service.get_backend_stats()
65
+ assert stats == {"hits": 1, "misses": 1, "evictions": 0, "entries": 1}
66
+
67
+
68
+ def test_backend_stats_reports_store_evictions() -> None:
69
+ """Filling past capacity is visible to the dashboard as evictions."""
70
+ service = _service()
71
+
72
+ async def _fill() -> None:
73
+ for index in range(6):
74
+ await service.set(f"key-{index}", index)
75
+
76
+ asyncio_run(_fill())
77
+ stats = service.get_backend_stats()
78
+ assert stats["entries"] == 3
79
+ assert stats["evictions"] == 3
80
+
81
+
82
+ def test_backend_stats_are_zeroed_when_the_backend_cannot_report() -> None:
83
+ """A backend without counters yields zeros, never fabricated activity."""
84
+
85
+ class _Opaque:
86
+ async def get(self, key: str) -> None:
87
+ return None
88
+
89
+ service = CacheService(backend=_Opaque())
90
+ assert service.get_backend_stats() == {
91
+ "hits": 0,
92
+ "misses": 0,
93
+ "evictions": 0,
94
+ "entries": 0,
95
+ }
96
+
97
+
98
+ def test_backend_stats_never_raise() -> None:
99
+ """A backend whose stats blow up reports zeros instead of failing a page."""
100
+
101
+ class _Exploding(MemoryCacheBackend):
102
+ def get_stats(self):
103
+ raise RuntimeError("stats unavailable")
104
+
105
+ service = CacheService(backend=_Exploding(max_size=1))
106
+ assert service.get_backend_stats()["entries"] == 0
107
+
108
+
109
+ def test_reset_stats_clears_evictions_too() -> None:
110
+ """Resetting counters clears the store's eviction count as well."""
111
+ service = _service()
112
+
113
+ async def _fill() -> None:
114
+ for index in range(6):
115
+ await service.set(f"key-{index}", index)
116
+
117
+ asyncio_run(_fill())
118
+ assert service.get_backend_stats()["evictions"] == 3
119
+
120
+ service.backend.reset_stats() # type: ignore[union-attr]
121
+ assert service.get_backend_stats()["evictions"] == 0
122
+
123
+
124
+ def asyncio_run(coro) -> None:
125
+ """Run a coroutine to completion without pytest-asyncio ceremony."""
126
+ import asyncio
127
+
128
+ asyncio.run(coro)
129
+
130
+
131
+ @pytest.mark.parametrize("key_prefix", ["", "gw:user:"])
132
+ def test_prefix_round_trips_through_the_service(key_prefix: str) -> None:
133
+ """The reported prefix is the one keys are actually built with."""
134
+ config = CacheOperationConfig(key_prefix=key_prefix)
135
+ service = CacheService(backend=MemoryCacheBackend(config=config))
136
+ assert service.key_prefix == key_prefix
@@ -0,0 +1,196 @@
1
+ """Guards for the ``StateStoreProtocol`` implementations.
2
+
3
+ ``StateStoreProtocol`` is a plain ``typing.Protocol`` whose members carry
4
+ docstring-only bodies. Subclassing it therefore does **not** make Python enforce
5
+ anything at runtime: an implementation that forgets a method silently inherits
6
+ the stub, and calling that method returns ``None`` instead of raising. Only
7
+ mypy notices.
8
+
9
+ That is exactly what happened — ``MemoryStateStore`` shipped without
10
+ ``expire``/``ttl``/``get_many``/``set_many`` and ``RedisStateStore`` without
11
+ ``exists`` on top of those four, so three call sites carried
12
+ ``# type: ignore[abstract]`` to silence the resulting
13
+ "cannot instantiate abstract class" errors.
14
+
15
+ The first test is the regression guard: it fails if any implementation starts
16
+ inheriting a protocol stub again. The rest pin the behaviour the protocol's
17
+ docstrings promise.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import inspect
23
+ from typing import Any
24
+
25
+ import pytest
26
+
27
+ from quadkit.cache.backends.memory_state import MemoryStateStore
28
+ from quadkit.cache.stores.redis_state import RedisStateStore
29
+ from quadkit.contracts.infra.state import StateStoreProtocol
30
+
31
+
32
+ def _protocol_methods() -> list[str]:
33
+ """Names declared directly on the protocol (not inherited dunders)."""
34
+ return sorted(
35
+ name
36
+ for name, member in vars(StateStoreProtocol).items()
37
+ if not name.startswith("_") and inspect.isfunction(member)
38
+ )
39
+
40
+
41
+ def _inherited_stub(cls: type, method: str) -> bool:
42
+ """True when *cls* resolves *method* to the protocol's own stub body."""
43
+ fn = getattr(cls, method, None)
44
+ if fn is None:
45
+ return True
46
+ return getattr(fn, "__qualname__", "").startswith("StateStoreProtocol")
47
+
48
+
49
+ class _FakeClient:
50
+ """Minimal async stand-in for ``redis.asyncio.Redis``."""
51
+
52
+ def __init__(self) -> None:
53
+ self.exists_result: Any = 1
54
+ self.expire_result: Any = True
55
+ self.ttl_result: Any = 42
56
+ self.mget_result: Any = []
57
+ self.set_calls: list[tuple] = []
58
+ self.expire_calls: list[tuple] = []
59
+
60
+ async def exists(self, key: str) -> Any:
61
+ return self.exists_result
62
+
63
+ async def expire(self, key: str, seconds: int) -> Any:
64
+ self.expire_calls.append((key, seconds))
65
+ return self.expire_result
66
+
67
+ async def ttl(self, key: str) -> Any:
68
+ return self.ttl_result
69
+
70
+ async def mget(self, keys: list[str]) -> Any:
71
+ return self.mget_result
72
+
73
+ async def set(self, key: str, value: str, ex: int | None = None) -> Any:
74
+ self.set_calls.append((key, value, ex))
75
+ return True
76
+
77
+
78
+ @pytest.mark.parametrize("cls", [MemoryStateStore, RedisStateStore])
79
+ def test_every_protocol_method_is_really_implemented(cls: type) -> None:
80
+ """No implementation may resolve a method to the protocol's empty stub."""
81
+ methods = _protocol_methods()
82
+ assert methods, "StateStoreProtocol declared nothing — the guard is vacuous"
83
+ inherited = [m for m in methods if _inherited_stub(cls, m)]
84
+ assert inherited == [], f"{cls.__name__} inherits protocol stubs for {inherited}"
85
+
86
+
87
+ class TestMemoryStateStoreContract:
88
+ @pytest.mark.asyncio
89
+ async def test_ttl_reports_no_expiry(self) -> None:
90
+ store = MemoryStateStore()
91
+ await store.set("k", "v")
92
+ assert await store.ttl("k") == -1
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_ttl_reports_absent_key(self) -> None:
96
+ store = MemoryStateStore()
97
+ assert await store.ttl("nope") == -2
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_ttl_reports_remaining_seconds(self) -> None:
101
+ store = MemoryStateStore()
102
+ await store.set("k", "v", ttl=60)
103
+ remaining = await store.ttl("k")
104
+ assert 0 < remaining <= 60
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_expire_sets_a_new_deadline(self) -> None:
108
+ store = MemoryStateStore()
109
+ await store.set("k", "v")
110
+ assert await store.expire("k", 30) is True
111
+ remaining = await store.ttl("k")
112
+ assert 0 < remaining <= 30
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_expire_on_missing_key_returns_false(self) -> None:
116
+ store = MemoryStateStore()
117
+ assert await store.expire("nope", 30) is False
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_expire_on_expired_key_returns_false_and_reaps(self) -> None:
121
+ store = MemoryStateStore()
122
+ await store.set("k", "v", ttl=-1) # already in the past
123
+ assert await store.expire("k", 30) is False
124
+ assert await store.get("k") is None
125
+
126
+ @pytest.mark.asyncio
127
+ async def test_get_many_omits_absent_keys(self) -> None:
128
+ store = MemoryStateStore()
129
+ await store.set("a", 1)
130
+ await store.set("b", {"nested": True})
131
+ assert await store.get_many(["a", "b", "missing"]) == {
132
+ "a": 1,
133
+ "b": {"nested": True},
134
+ }
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_set_many_applies_ttl_to_every_entry(self) -> None:
138
+ store = MemoryStateStore()
139
+ await store.set_many({"a": 1, "b": 2}, ttl=120)
140
+ assert await store.get_many(["a", "b"]) == {"a": 1, "b": 2}
141
+ for key in ("a", "b"):
142
+ remaining = await store.ttl(key)
143
+ assert 0 < remaining <= 120
144
+
145
+
146
+ class TestRedisStateStoreContract:
147
+ @pytest.mark.asyncio
148
+ async def test_exists_maps_truthiness(self) -> None:
149
+ client = _FakeClient()
150
+ store = RedisStateStore(client=client)
151
+ assert await store.exists("k") is True
152
+ client.exists_result = 0
153
+ assert await store.exists("k") is False
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_expire_forwards_key_and_seconds(self) -> None:
157
+ client = _FakeClient()
158
+ store = RedisStateStore(prefix="p:", client=client)
159
+ assert await store.expire("k", 90) is True
160
+ assert client.expire_calls == [("p:k", 90)]
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_expire_returns_false_when_key_missing(self) -> None:
164
+ client = _FakeClient()
165
+ client.expire_result = False
166
+ store = RedisStateStore(client=client)
167
+ assert await store.expire("k", 90) is False
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_ttl_passes_through_redis_sentinels(self) -> None:
171
+ client = _FakeClient()
172
+ store = RedisStateStore(client=client)
173
+ assert await store.ttl("k") == 42
174
+ client.ttl_result = -1
175
+ assert await store.ttl("k") == -1
176
+ client.ttl_result = -2
177
+ assert await store.ttl("k") == -2
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_get_many_returns_found_keys_only(self) -> None:
181
+ client = _FakeClient()
182
+ client.mget_result = ['{"a":1}', None]
183
+ store = RedisStateStore(client=client)
184
+ assert await store.get_many(["a", "b"]) == {"a": {"a": 1}}
185
+
186
+ @pytest.mark.asyncio
187
+ async def test_set_many_writes_each_entry_with_ttl(self) -> None:
188
+ client = _FakeClient()
189
+ store = RedisStateStore(client=client)
190
+ await store.set_many({"a": 1, "b": {"n": 2}}, ttl=15)
191
+ # Each entry is serialised on its own: `set_many` forwards the value,
192
+ # not a wrapper dict.
193
+ assert list(client.set_calls) == [
194
+ ("a", b"1", 15),
195
+ ("b", b'{"n":2}', 15),
196
+ ]
File without changes
File without changes
File without changes