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.
@@ -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,3 @@
1
+ # varco-memcached
2
+
3
+ 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