varco-memcached 0.1.0__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.
- varco_memcached-0.1.0/.gitignore +209 -0
- varco_memcached-0.1.0/PKG-INFO +28 -0
- varco_memcached-0.1.0/README.md +3 -0
- varco_memcached-0.1.0/pyproject.toml +61 -0
- varco_memcached-0.1.0/tests/__init__.py +0 -0
- varco_memcached-0.1.0/tests/test_cache.py +378 -0
- varco_memcached-0.1.0/tests/test_integration.py +155 -0
- varco_memcached-0.1.0/varco_memcached/__init__.py +41 -0
- varco_memcached-0.1.0/varco_memcached/cache.py +594 -0
- varco_memcached-0.1.0/varco_memcached/py.typed +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
.python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
todo*.txt
|
|
205
|
+
|
|
206
|
+
# Marimo
|
|
207
|
+
marimo/_static/
|
|
208
|
+
marimo/_lsp/
|
|
209
|
+
__marimo__/
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: varco-memcached
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Memcached cache backend for varco — MemcachedCache built on aiomcache
|
|
5
|
+
Project-URL: Homepage, https://github.com/edoardoscarpaci/varco
|
|
6
|
+
Project-URL: Repository, https://github.com/edoardoscarpaci/varco/tree/main/varco_memcached
|
|
7
|
+
Project-URL: Issues, https://github.com/edoardoscarpaci/varco/issues
|
|
8
|
+
Author-email: "edoardo.scarpaci" <edoardo.scarpaci@gmail.com>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: async,cache,fastapi,memcached,varco
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: aiomcache>=0.8
|
|
21
|
+
Requires-Dist: providify>=0.1.4a3
|
|
22
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
23
|
+
Requires-Dist: varco-core
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# varco-memcached
|
|
27
|
+
|
|
28
|
+
Memcached cache backend for the [varco](https://github.com/edoardoscarpaci/varco) framework.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "varco-memcached"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Memcached cache backend for varco — MemcachedCache built on aiomcache"
|
|
5
|
+
authors = [{ name = "edoardo.scarpaci", email = "edoardo.scarpaci@gmail.com" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
keywords = ["fastapi", "memcached", "cache", "async", "varco"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: Apache Software License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
# Workspace sibling — resolved from the monorepo via [tool.uv.sources] below.
|
|
22
|
+
"varco-core",
|
|
23
|
+
# aiomcache is a pure-Python asyncio Memcached client.
|
|
24
|
+
"aiomcache>=0.8",
|
|
25
|
+
# Used for @Configuration / @Provider in MemcachedCacheConfiguration.
|
|
26
|
+
"providify>=0.1.4a3",
|
|
27
|
+
# Used for MemcachedCacheSettings — SettingsConfigDict + BaseSettings.
|
|
28
|
+
"pydantic-settings>=2.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/edoardoscarpaci/varco"
|
|
33
|
+
Repository = "https://github.com/edoardoscarpaci/varco/tree/main/varco_memcached"
|
|
34
|
+
Issues = "https://github.com/edoardoscarpaci/varco/issues"
|
|
35
|
+
|
|
36
|
+
[tool.uv.sources]
|
|
37
|
+
# Resolve varco-core from the workspace so editable changes are visible immediately.
|
|
38
|
+
varco-core = { workspace = true }
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=8.0",
|
|
43
|
+
"pytest-asyncio>=0.24",
|
|
44
|
+
# testcontainers[memcached] spins up a real Memcached instance via Docker for
|
|
45
|
+
# integration tests. Marked @pytest.mark.integration — run with: pytest -m integration
|
|
46
|
+
"testcontainers>=4.0",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["hatchling"]
|
|
51
|
+
build-backend = "hatchling.build"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["varco_memcached"]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
asyncio_mode = "auto"
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
markers = [
|
|
60
|
+
"integration: real Memcached instance required (disabled by default — use -m integration)",
|
|
61
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for varco_memcached.cache
|
|
3
|
+
======================================
|
|
4
|
+
All tests mock ``aiomcache.Client`` — no real Memcached instance required.
|
|
5
|
+
|
|
6
|
+
A ``FakeMemcached`` test double replaces ``aiomcache.Client`` via
|
|
7
|
+
``unittest.mock.patch``, providing an in-memory dict-backed implementation
|
|
8
|
+
that mirrors the aiomcache commands used by ``MemcachedCache``
|
|
9
|
+
(``get``, ``set``, ``delete``, ``close``).
|
|
10
|
+
|
|
11
|
+
DESIGN: FakeMemcached stores keys as bytes
|
|
12
|
+
aiomcache requires ``bytes`` keys — the fake intentionally uses
|
|
13
|
+
``dict[bytes, bytes]`` to surface any caller that forgets to encode.
|
|
14
|
+
|
|
15
|
+
Sections
|
|
16
|
+
--------
|
|
17
|
+
- ``MemcachedCacheSettings`` — defaults, memcached_key(), frozen, env prefix
|
|
18
|
+
- ``MemcachedCache`` lifecycle — start/stop, double-start guard, context manager
|
|
19
|
+
- ``MemcachedCache`` set/get — round-trip, TTL (exptime), no-TTL, prefix
|
|
20
|
+
- ``MemcachedCache`` delete — removes key; removes from registry; idempotent
|
|
21
|
+
- ``MemcachedCache`` exists — true/false; strategy gate
|
|
22
|
+
- ``MemcachedCache`` clear — only removes registry keys; leaves untracked keys
|
|
23
|
+
- ``MemcachedCache`` strategy — ExplicitStrategy gates get/exists
|
|
24
|
+
- ``MemcachedCacheConfiguration`` — wires settings + cache via Providify
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from unittest.mock import patch
|
|
30
|
+
|
|
31
|
+
import pytest
|
|
32
|
+
|
|
33
|
+
from varco_core.cache import ExplicitStrategy
|
|
34
|
+
from varco_memcached.cache import (
|
|
35
|
+
MemcachedCache,
|
|
36
|
+
MemcachedCacheConfiguration,
|
|
37
|
+
MemcachedCacheSettings,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── FakeMemcached ───────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FakeMemcached:
|
|
45
|
+
"""
|
|
46
|
+
In-memory Memcached fake.
|
|
47
|
+
|
|
48
|
+
Keys and values are stored as ``bytes`` — matching the aiomcache contract.
|
|
49
|
+
Supports: get, set, delete, close.
|
|
50
|
+
|
|
51
|
+
Does NOT implement real TTL expiry — tests that need TTL expiry use
|
|
52
|
+
real Memcached via the integration test suite.
|
|
53
|
+
|
|
54
|
+
Async safety: ✅ — all methods are ``async def``, safe to await in tests.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
# bytes → bytes mapping mirrors aiomcache's raw byte storage.
|
|
59
|
+
self._store: dict[bytes, bytes] = {}
|
|
60
|
+
# Track the exptime passed to set() — used to assert TTL behaviour.
|
|
61
|
+
self._exptime: dict[bytes, int] = {}
|
|
62
|
+
|
|
63
|
+
async def get(self, key: bytes) -> bytes | None:
|
|
64
|
+
return self._store.get(key)
|
|
65
|
+
|
|
66
|
+
async def set(self, key: bytes, value: bytes, exptime: int = 0) -> bool:
|
|
67
|
+
self._store[key] = value
|
|
68
|
+
self._exptime[key] = exptime
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
async def delete(self, key: bytes) -> bool:
|
|
72
|
+
# Idempotent — do not raise on missing key.
|
|
73
|
+
self._store.pop(key, None)
|
|
74
|
+
self._exptime.pop(key, None)
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
async def close(self) -> None:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Fixtures ─────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.fixture
|
|
85
|
+
def fake_mc() -> FakeMemcached:
|
|
86
|
+
return FakeMemcached()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.fixture
|
|
90
|
+
def settings() -> MemcachedCacheSettings:
|
|
91
|
+
return MemcachedCacheSettings(host="fake-mc", port=11211)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.fixture
|
|
95
|
+
async def cache(
|
|
96
|
+
settings: MemcachedCacheSettings,
|
|
97
|
+
fake_mc: FakeMemcached,
|
|
98
|
+
) -> MemcachedCache:
|
|
99
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
100
|
+
async with MemcachedCache(settings) as c:
|
|
101
|
+
yield c
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── MemcachedCacheSettings ──────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestMemcachedCacheSettings:
|
|
108
|
+
def test_defaults(self) -> None:
|
|
109
|
+
s = MemcachedCacheSettings()
|
|
110
|
+
assert s.host == "localhost"
|
|
111
|
+
assert s.port == 11211
|
|
112
|
+
assert s.pool_size == 2
|
|
113
|
+
assert s.key_prefix == ""
|
|
114
|
+
assert s.default_ttl is None
|
|
115
|
+
|
|
116
|
+
def test_frozen(self) -> None:
|
|
117
|
+
s = MemcachedCacheSettings()
|
|
118
|
+
with pytest.raises(Exception):
|
|
119
|
+
s.host = "other" # type: ignore[misc]
|
|
120
|
+
|
|
121
|
+
def test_memcached_key_no_prefix(self) -> None:
|
|
122
|
+
s = MemcachedCacheSettings()
|
|
123
|
+
assert s.memcached_key("user:1") == b"user:1"
|
|
124
|
+
|
|
125
|
+
def test_memcached_key_with_prefix(self) -> None:
|
|
126
|
+
s = MemcachedCacheSettings(key_prefix="myapp:")
|
|
127
|
+
assert s.memcached_key("user:1") == b"myapp:user:1"
|
|
128
|
+
|
|
129
|
+
def test_from_dict(self) -> None:
|
|
130
|
+
s = MemcachedCacheSettings.from_dict(
|
|
131
|
+
{"host": "mc.prod", "port": 11212, "key_prefix": "prod:"}
|
|
132
|
+
)
|
|
133
|
+
assert s.host == "mc.prod"
|
|
134
|
+
assert s.port == 11212
|
|
135
|
+
assert s.key_prefix == "prod:"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── MemcachedCache lifecycle ────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestMemcachedCacheLifecycle:
|
|
142
|
+
async def test_not_started_raises_on_get(
|
|
143
|
+
self, settings: MemcachedCacheSettings
|
|
144
|
+
) -> None:
|
|
145
|
+
c = MemcachedCache(settings)
|
|
146
|
+
with pytest.raises(RuntimeError, match="not started"):
|
|
147
|
+
await c.get("k")
|
|
148
|
+
|
|
149
|
+
async def test_not_started_raises_on_set(
|
|
150
|
+
self, settings: MemcachedCacheSettings
|
|
151
|
+
) -> None:
|
|
152
|
+
c = MemcachedCache(settings)
|
|
153
|
+
with pytest.raises(RuntimeError, match="not started"):
|
|
154
|
+
await c.set("k", "v")
|
|
155
|
+
|
|
156
|
+
async def test_double_start_raises(
|
|
157
|
+
self, settings: MemcachedCacheSettings, fake_mc: FakeMemcached
|
|
158
|
+
) -> None:
|
|
159
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
160
|
+
c = MemcachedCache(settings)
|
|
161
|
+
await c.start()
|
|
162
|
+
with pytest.raises(RuntimeError, match="already-started"):
|
|
163
|
+
await c.start()
|
|
164
|
+
await c.stop()
|
|
165
|
+
|
|
166
|
+
async def test_stop_before_start_noop(
|
|
167
|
+
self, settings: MemcachedCacheSettings
|
|
168
|
+
) -> None:
|
|
169
|
+
c = MemcachedCache(settings)
|
|
170
|
+
await c.stop() # must not raise
|
|
171
|
+
|
|
172
|
+
async def test_context_manager_starts_and_stops(
|
|
173
|
+
self, settings: MemcachedCacheSettings, fake_mc: FakeMemcached
|
|
174
|
+
) -> None:
|
|
175
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
176
|
+
async with MemcachedCache(settings) as c:
|
|
177
|
+
assert c._client is not None
|
|
178
|
+
assert c._client is None
|
|
179
|
+
|
|
180
|
+
async def test_repr_started(self, cache: MemcachedCache) -> None:
|
|
181
|
+
text = repr(cache)
|
|
182
|
+
assert "MemcachedCache" in text
|
|
183
|
+
assert "started=True" in text
|
|
184
|
+
|
|
185
|
+
async def test_repr_not_started(self, settings: MemcachedCacheSettings) -> None:
|
|
186
|
+
text = repr(MemcachedCache(settings))
|
|
187
|
+
assert "started=False" in text
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── MemcachedCache set / get ────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TestMemcachedCacheSetGet:
|
|
194
|
+
async def test_set_and_get_roundtrip(self, cache: MemcachedCache) -> None:
|
|
195
|
+
await cache.set("user:1", {"name": "Alice", "age": 30})
|
|
196
|
+
result = await cache.get("user:1")
|
|
197
|
+
assert result == {"name": "Alice", "age": 30}
|
|
198
|
+
|
|
199
|
+
async def test_get_missing_returns_none(self, cache: MemcachedCache) -> None:
|
|
200
|
+
assert await cache.get("missing") is None
|
|
201
|
+
|
|
202
|
+
async def test_set_with_ttl_passes_exptime(
|
|
203
|
+
self,
|
|
204
|
+
cache: MemcachedCache,
|
|
205
|
+
fake_mc: FakeMemcached,
|
|
206
|
+
settings: MemcachedCacheSettings,
|
|
207
|
+
) -> None:
|
|
208
|
+
await cache.set("k", "v", ttl=120)
|
|
209
|
+
mc_key = settings.memcached_key("k")
|
|
210
|
+
assert fake_mc._exptime.get(mc_key) == 120
|
|
211
|
+
|
|
212
|
+
async def test_set_without_ttl_uses_zero_exptime(
|
|
213
|
+
self,
|
|
214
|
+
cache: MemcachedCache,
|
|
215
|
+
fake_mc: FakeMemcached,
|
|
216
|
+
settings: MemcachedCacheSettings,
|
|
217
|
+
) -> None:
|
|
218
|
+
# 0 = no expiry in Memcached protocol
|
|
219
|
+
await cache.set("k", "v")
|
|
220
|
+
mc_key = settings.memcached_key("k")
|
|
221
|
+
assert fake_mc._exptime.get(mc_key) == 0
|
|
222
|
+
|
|
223
|
+
async def test_default_ttl_from_settings(self, fake_mc: FakeMemcached) -> None:
|
|
224
|
+
settings = MemcachedCacheSettings(host="fake-mc", port=11211, default_ttl=60.0)
|
|
225
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
226
|
+
async with MemcachedCache(settings) as cache:
|
|
227
|
+
await cache.set("k", "v")
|
|
228
|
+
mc_key = settings.memcached_key("k")
|
|
229
|
+
assert fake_mc._exptime.get(mc_key) == 60
|
|
230
|
+
|
|
231
|
+
async def test_key_prefix_applied_in_storage(self, fake_mc: FakeMemcached) -> None:
|
|
232
|
+
settings = MemcachedCacheSettings(
|
|
233
|
+
host="fake-mc", port=11211, key_prefix="myapp:"
|
|
234
|
+
)
|
|
235
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
236
|
+
async with MemcachedCache(settings) as cache:
|
|
237
|
+
await cache.set("user:1", "Alice")
|
|
238
|
+
assert b"myapp:user:1" in fake_mc._store
|
|
239
|
+
|
|
240
|
+
async def test_set_tracks_key_in_registry(self, cache: MemcachedCache) -> None:
|
|
241
|
+
await cache.set("user:1", "Alice")
|
|
242
|
+
# The logical key (unprefixed) must be tracked.
|
|
243
|
+
assert "user:1" in cache._key_registry
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── MemcachedCache delete ───────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestMemcachedCacheDelete:
|
|
250
|
+
async def test_delete_removes_key(self, cache: MemcachedCache) -> None:
|
|
251
|
+
await cache.set("k", "v")
|
|
252
|
+
await cache.delete("k")
|
|
253
|
+
assert await cache.get("k") is None
|
|
254
|
+
|
|
255
|
+
async def test_delete_removes_from_registry(self, cache: MemcachedCache) -> None:
|
|
256
|
+
await cache.set("k", "v")
|
|
257
|
+
assert "k" in cache._key_registry
|
|
258
|
+
await cache.delete("k")
|
|
259
|
+
assert "k" not in cache._key_registry
|
|
260
|
+
|
|
261
|
+
async def test_delete_nonexistent_noop(self, cache: MemcachedCache) -> None:
|
|
262
|
+
await cache.delete("missing") # must not raise
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── MemcachedCache exists ───────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TestMemcachedCacheExists:
|
|
269
|
+
async def test_exists_true(self, cache: MemcachedCache) -> None:
|
|
270
|
+
await cache.set("k", "v")
|
|
271
|
+
assert await cache.exists("k") is True
|
|
272
|
+
|
|
273
|
+
async def test_exists_false_for_missing(self, cache: MemcachedCache) -> None:
|
|
274
|
+
assert await cache.exists("missing") is False
|
|
275
|
+
|
|
276
|
+
async def test_exists_false_when_strategy_invalidates(
|
|
277
|
+
self, fake_mc: FakeMemcached, settings: MemcachedCacheSettings
|
|
278
|
+
) -> None:
|
|
279
|
+
strategy = ExplicitStrategy()
|
|
280
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
281
|
+
async with MemcachedCache(settings, strategy=strategy) as cache:
|
|
282
|
+
await cache.set("k", "v")
|
|
283
|
+
strategy.invalidate("k")
|
|
284
|
+
assert await cache.exists("k") is False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ── MemcachedCache clear ────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class TestMemcachedCacheClear:
|
|
291
|
+
async def test_clear_removes_tracked_keys(self, cache: MemcachedCache) -> None:
|
|
292
|
+
await cache.set("a", 1)
|
|
293
|
+
await cache.set("b", 2)
|
|
294
|
+
await cache.clear()
|
|
295
|
+
assert await cache.get("a") is None
|
|
296
|
+
assert await cache.get("b") is None
|
|
297
|
+
|
|
298
|
+
async def test_clear_empties_registry(self, cache: MemcachedCache) -> None:
|
|
299
|
+
await cache.set("a", 1)
|
|
300
|
+
await cache.set("b", 2)
|
|
301
|
+
await cache.clear()
|
|
302
|
+
assert len(cache._key_registry) == 0
|
|
303
|
+
|
|
304
|
+
async def test_clear_does_not_remove_untracked_keys(
|
|
305
|
+
self, fake_mc: FakeMemcached, settings: MemcachedCacheSettings
|
|
306
|
+
) -> None:
|
|
307
|
+
# Put a key directly into the fake store (simulating another client).
|
|
308
|
+
untracked_key = b"other:key"
|
|
309
|
+
fake_mc._store[untracked_key] = b'"external"'
|
|
310
|
+
|
|
311
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
312
|
+
async with MemcachedCache(settings) as cache:
|
|
313
|
+
await cache.set("a", 1)
|
|
314
|
+
await cache.clear()
|
|
315
|
+
# The externally-written key must survive.
|
|
316
|
+
assert untracked_key in fake_mc._store
|
|
317
|
+
|
|
318
|
+
async def test_clear_noop_when_empty(self, cache: MemcachedCache) -> None:
|
|
319
|
+
await cache.clear() # must not raise
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ── MemcachedCache + ExplicitStrategy ───────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class TestMemcachedCacheWithStrategy:
|
|
326
|
+
async def test_strategy_invalidates_get(
|
|
327
|
+
self, fake_mc: FakeMemcached, settings: MemcachedCacheSettings
|
|
328
|
+
) -> None:
|
|
329
|
+
strategy = ExplicitStrategy()
|
|
330
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
331
|
+
async with MemcachedCache(settings, strategy=strategy) as cache:
|
|
332
|
+
await cache.set("k", "v")
|
|
333
|
+
strategy.invalidate("k")
|
|
334
|
+
result = await cache.get("k")
|
|
335
|
+
assert result is None
|
|
336
|
+
# Key must be evicted from Memcached too.
|
|
337
|
+
assert await fake_mc.get(settings.memcached_key("k")) is None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ── MemcachedCacheConfiguration ─────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class TestMemcachedCacheConfiguration:
|
|
344
|
+
async def test_provides_cache_backend(self, fake_mc: FakeMemcached) -> None:
|
|
345
|
+
from providify import DIContainer
|
|
346
|
+
from varco_core.cache import CacheBackend
|
|
347
|
+
|
|
348
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
349
|
+
container = DIContainer()
|
|
350
|
+
await container.ainstall(MemcachedCacheConfiguration)
|
|
351
|
+
|
|
352
|
+
cache = await container.aget(CacheBackend)
|
|
353
|
+
assert isinstance(cache, MemcachedCache)
|
|
354
|
+
|
|
355
|
+
await container.ashutdown()
|
|
356
|
+
|
|
357
|
+
async def test_singleton_returns_same_instance(
|
|
358
|
+
self, fake_mc: FakeMemcached
|
|
359
|
+
) -> None:
|
|
360
|
+
from providify import DIContainer
|
|
361
|
+
from varco_core.cache import CacheBackend
|
|
362
|
+
|
|
363
|
+
with patch("varco_memcached.cache.aiomcache.Client", return_value=fake_mc):
|
|
364
|
+
container = DIContainer()
|
|
365
|
+
await container.ainstall(MemcachedCacheConfiguration)
|
|
366
|
+
|
|
367
|
+
c1 = await container.aget(CacheBackend)
|
|
368
|
+
c2 = await container.aget(CacheBackend)
|
|
369
|
+
assert c1 is c2
|
|
370
|
+
|
|
371
|
+
await container.ashutdown()
|
|
372
|
+
|
|
373
|
+
async def test_settings_default_to_localhost(self) -> None:
|
|
374
|
+
# Settings created without arguments must default to localhost:11211 —
|
|
375
|
+
# verifies the DI configuration provider reads sane defaults from env.
|
|
376
|
+
s = MemcachedCacheSettings()
|
|
377
|
+
assert s.host == "localhost"
|
|
378
|
+
assert s.port == 11211
|