inhouse-cache 0.1.1__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,177 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
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
+
110
+ # pdm
111
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112
+ #pdm.lock
113
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114
+ # in version control.
115
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116
+ .pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121
+ __pypackages__/
122
+
123
+ # Celery stuff
124
+ celerybeat-schedule
125
+ celerybeat.pid
126
+
127
+ # SageMath parsed files
128
+ *.sage.py
129
+
130
+ # Environments
131
+ .env
132
+ .venv
133
+ env/
134
+ venv/
135
+ ENV/
136
+ env.bak/
137
+ venv.bak/
138
+
139
+ # Spyder project settings
140
+ .spyderproject
141
+ .spyproject
142
+
143
+ # Rope project settings
144
+ .ropeproject
145
+
146
+ # mkdocs documentation
147
+ /site
148
+
149
+ # mypy
150
+ .mypy_cache/
151
+ .dmypy.json
152
+ dmypy.json
153
+
154
+ # Pyre type checker
155
+ .pyre/
156
+
157
+ # pytype static type analyzer
158
+ .pytype/
159
+
160
+ # Cython debug symbols
161
+ cython_debug/
162
+
163
+ # PyCharm
164
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
167
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168
+ #.idea/
169
+
170
+ # Ruff stuff:
171
+ .ruff_cache/
172
+
173
+ # PyPI configuration file
174
+ .pypirc
175
+
176
+ # Local development notes
177
+ .working_sessions/*
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kineticquant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,325 @@
1
+ Metadata-Version: 2.4
2
+ Name: inhouse-cache
3
+ Version: 0.1.1
4
+ Summary: Zero-dependency, in-process TTL cache for Python. Optional FastAPI decorators, stampede-safe, LRU-bounded.
5
+ Project-URL: Homepage, https://github.com/kineticquant/inhouse
6
+ Project-URL: Repository, https://github.com/kineticquant/inhouse
7
+ Project-URL: Issues, https://github.com/kineticquant/inhouse/issues
8
+ Author-email: Michael Mooney <inhouse@rancero.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: async,cache,fastapi,lru,ttl
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: dev
23
+ Requires-Dist: fastapi>=0.100; extra == 'dev'
24
+ Requires-Dist: httpx>=0.27; extra == 'dev'
25
+ Requires-Dist: mypy>=1.10; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Provides-Extra: fastapi
30
+ Requires-Dist: fastapi>=0.100; extra == 'fastapi'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # inhouse
34
+
35
+ **Zero-dependency, in-process TTL cache for Python.** One decorator, stampede-safe, LRU-bounded. For when Redis is a meeting you don't want to have, or when you need to avoid yet another deployment. Designed to be simple and effective without bloat or complexity for developers.
36
+
37
+ Designed for easy use with FastAPI applications. Although FastAPI integration is absolutely optional.
38
+
39
+ ## Install
40
+
41
+ The package is published on PyPI as **`inhouse-cache`**. Imports use `inhouse` (e.g. `from inhouse import MemoryStore`).
42
+
43
+ Core:
44
+ ```bash
45
+ pip install inhouse-cache
46
+ ```
47
+
48
+ With FastAPI helpers (`fastapi_cache`, lifespan sweeper):
49
+ ```bash
50
+ pip install inhouse-cache[fastapi]
51
+ ```
52
+
53
+ ## Quick start Usage
54
+
55
+ ### Core (any Python project)
56
+
57
+ ```python
58
+ from inhouse import MemoryStore, inhouse_cache
59
+
60
+ store = MemoryStore(max_size=1024, default_ttl=60)
61
+
62
+
63
+ @inhouse_cache(store=store)
64
+ async def load_user(user_id: int) -> dict[str, int]:
65
+ return {"user_id": user_id}
66
+ ```
67
+
68
+ Works with both `async def` and `def` callables.
69
+
70
+ ### FastAPI Use Case
71
+
72
+ ```python
73
+ import asyncio
74
+
75
+ from fastapi import FastAPI
76
+
77
+ from inhouse import MemoryStore
78
+ from inhouse.fastapi import create_lifespan, fastapi_cache
79
+
80
+ store = MemoryStore(max_size=1024, default_ttl=60)
81
+ app = FastAPI(lifespan=create_lifespan(store))
82
+
83
+
84
+ @app.get("/items/{item_id}")
85
+ @fastapi_cache(store=store)
86
+ async def get_item(item_id: int) -> dict[str, int]:
87
+ await asyncio.sleep(0.1) # expensive work
88
+ return {"item_id": item_id}
89
+ ```
90
+
91
+ Requires `pip install inhouse-cache[fastapi]`.
92
+
93
+ ## Features
94
+
95
+ **Core (zero dependencies)**
96
+
97
+ - TTL cache with lazy expiry on read
98
+ - LRU eviction when `max_size` is exceeded
99
+ - Per-key singleflight stampede guard - concurrent misses on the same key coalesce to one computation. Errors and cancellations propagate to all waiters (no hung followers on shutdown)
100
+ - Deterministic cache keys - canonical JSON serialization. Keyword argument order and Request subclasses don't cause spurious cache misses
101
+ - Thread-safe store for sync and async callables
102
+ - Fixed, store-default, or callable TTL on each cache write
103
+
104
+ **Optional FastAPI extra** (`pip install inhouse-cache[fastapi]`)
105
+
106
+ - `@fastapi_cache` with Request/Response-aware cache keys
107
+ - Background expiry sweeper via FastAPI lifespan helpers
108
+ - Clean lifespan shutdown - background sweeper cancels without noisy tracebacks
109
+
110
+ ## Configuration reference
111
+
112
+ ### `MemoryStore`
113
+
114
+ ```python
115
+ from inhouse import MemoryStore
116
+
117
+ store = MemoryStore(max_size=1024, default_ttl=60)
118
+ ```
119
+
120
+ | Parameter / attribute | Type | Default | Description |
121
+ |---|---|---|---|
122
+ | `max_size` | `int` | `1024` | Maximum number of entries before LRU eviction |
123
+ | `default_ttl` | `float \| None` | `None` | Default TTL in seconds for `store.set()` and decorators that omit `ttl_seconds` |
124
+ | `default_ttl` (property) | `float \| None` | — | Mutable at runtime; affects **future** writes only |
125
+ | `size` | `int` (read-only) | — | Current number of cached entries |
126
+
127
+ Store methods:
128
+
129
+ | Method | Description |
130
+ |---|---|
131
+ | `get(key, *, default=MISS)` | Return a cached value, or `default` on miss/expiry |
132
+ | `set(key, value, ttl_seconds=None)` | Write a value; uses `default_ttl` when `ttl_seconds` is omitted |
133
+ | `delete(key)` | Remove one entry |
134
+ | `clear()` | Remove all entries |
135
+ | `purge_expired()` | Proactively delete expired entries |
136
+ | `keys()` | List current cache keys |
137
+
138
+ ### `@inhouse_cache` / `cache()`
139
+
140
+ Core decorator. Works with both `async def` and `def` callables.
141
+
142
+ ```python
143
+ from inhouse import MemoryStore, inhouse_cache, make_cache_key
144
+
145
+ store = MemoryStore(default_ttl=60)
146
+
147
+ @inhouse_cache(
148
+ ttl_seconds=60, # optional — see Dynamic TTL below
149
+ store=store, # optional — defaults to a module-level store
150
+ key_builder=make_cache_key, # optional — custom cache key strategy
151
+ exclude_types=(object,), # optional — types omitted from key material
152
+ )
153
+ async def load_user(user_id: int) -> dict[str, int]:
154
+ return {"user_id": user_id}
155
+ ```
156
+
157
+ | Parameter | Type | Default | Description |
158
+ |---|---|---|---|
159
+ | `ttl_seconds` | `float \| Callable[[], float] \| None` | `None` | TTL in seconds for each cache write. See [Dynamic TTL](#dynamic-ttl). |
160
+ | `store` | `MemoryStore \| None` | module default | Cache instance to read/write |
161
+ | `key_builder` | `Callable[..., str]` | `make_cache_key` | Builds the cache key from function identity + arguments |
162
+ | `exclude_types` | `tuple[type, ...]` | `()` | Argument types excluded from key material (e.g. request objects) |
163
+
164
+ `inhouse_cache` is an alias for `cache`.
165
+
166
+ Global default store helpers:
167
+
168
+ ```python
169
+ from inhouse import configure_default_store, get_default_store
170
+
171
+ store = MemoryStore(default_ttl=120)
172
+ configure_default_store(store)
173
+
174
+ @inhouse_cache() # uses the configured default store + its default_ttl
175
+ async def load_config() -> dict[str, str]:
176
+ ...
177
+ ```
178
+
179
+ ### `@fastapi_cache` *(optional — requires `inhouse-cache[fastapi]`)*
180
+
181
+ FastAPI-friendly wrapper around `inhouse_cache`. Automatically excludes Starlette `Request` and `Response` objects from cache keys.
182
+
183
+ ```python
184
+ from inhouse.fastapi import create_lifespan, fastapi_cache
185
+
186
+ store = MemoryStore(max_size=512, default_ttl=60)
187
+ app = FastAPI(lifespan=create_lifespan(store, sweep_interval=30.0))
188
+
189
+ @app.get("/items/{item_id}")
190
+ @fastapi_cache(store=store)
191
+ async def get_item(item_id: int) -> dict[str, int]:
192
+ ...
193
+ ```
194
+
195
+ | Parameter | Type | Default | Description |
196
+ |---|---|---|---|
197
+ | `ttl_seconds` | `float \| Callable[[], float] \| None` | `None` | Same semantics as `@inhouse_cache` |
198
+ | `store` | `MemoryStore \| None` | module default | Cache instance to read/write |
199
+
200
+ `fastapi_cache` does not expose `key_builder` or `exclude_types`; it always uses the FastAPI-aware key builder.
201
+
202
+ ### Lifespan / background cleanup *(optional — requires `inhouse-cache[fastapi]`)*
203
+
204
+ ```python
205
+ from inhouse.fastapi import create_lifespan, inhouse_lifespan
206
+
207
+ # Option A: pass directly to FastAPI
208
+ app = FastAPI(lifespan=create_lifespan(store, sweep_interval=30.0))
209
+
210
+ # Option B: use inside your own lifespan
211
+ async with inhouse_lifespan(store, sweep_interval=30.0):
212
+ ...
213
+ ```
214
+
215
+ | Parameter | Type | Default | Description |
216
+ |---|---|---|---|
217
+ | `store` | `MemoryStore` | required | Store to sweep for expired entries |
218
+ | `sweep_interval` | `float` | `30.0` | Seconds between background purge runs |
219
+
220
+ ## Dynamic TTL
221
+
222
+ TTL is resolved when a value is **written** to the cache (on a miss), not on every read. Changing TTL settings does not retroactively extend entries already stored.
223
+
224
+ Three ways to configure expiration:
225
+
226
+ ### 1. Fixed TTL (per route)
227
+
228
+ ```python
229
+ @inhouse_cache(60, store=store)
230
+ async def load_user(user_id: int) -> dict[str, int]:
231
+ ...
232
+ ```
233
+
234
+ Always expires 60 seconds after the value is cached.
235
+
236
+ ### 2. Store default (mutable at runtime)
237
+
238
+ ```python
239
+ store = MemoryStore(default_ttl=60)
240
+
241
+ @inhouse_cache(store=store)
242
+ async def load_config() -> dict[str, str]:
243
+ ...
244
+
245
+ # Later - affects future cache writes only
246
+ store.default_ttl = 300
247
+ ```
248
+
249
+ Omitting `ttl_seconds` on the decorator uses `store.default_ttl`. If both are missing, inhouse raises `ValueError`.
250
+
251
+ `store.default_ttl` is safe to change at runtime from other threads; new writes pick up the updated value atomically.
252
+
253
+ ### 3. Callable TTL (evaluated on each write)
254
+
255
+ ```python
256
+ settings = {"cache_ttl": 60}
257
+
258
+ @inhouse_cache(lambda: settings["cache_ttl"], store=store)
259
+ async def load_dashboard() -> dict[str, str]:
260
+ ...
261
+
262
+ settings["cache_ttl"] = 300 # next cache miss uses 300 seconds
263
+ ```
264
+
265
+ Useful for feature flags, config files, or environment-driven TTL without redeploying.
266
+
267
+ ### Priority order
268
+
269
+ When a cache miss is written, TTL is resolved as:
270
+
271
+ 1. Callable `ttl_seconds()` result, if a callable was passed
272
+ 2. Fixed `ttl_seconds` float, if provided
273
+ 3. `store.default_ttl`, if set
274
+ 4. Otherwise → `ValueError`
275
+
276
+ ## When to use inhouse
277
+
278
+ | Scenario | inhouse | Redis | fastapi-cache2 |
279
+ |---|---|---|---|
280
+ | Single-node FastAPI prototype | Great | Overkill | Great |
281
+ | Zero external infrastructure | Yes | No | Depends on backend |
282
+ | Distributed multi-instance cache | No | Yes | Yes (with Redis) |
283
+ | Decorator-first developer UX | Yes | No | Yes |
284
+
285
+ ## Important limitations
286
+
287
+ inhouse is **per-process** memory. If you run `uvicorn main:app --workers 4`, each worker maintains its own independent cache. That keeps the design simple and avoids shared infrastructure. It is not a distributed cache.
288
+
289
+ ## Architecture
290
+
291
+ ```mermaid
292
+ flowchart TB
293
+ subgraph fastapi_layer [Optional FastAPI Layer]
294
+ Decorator["@fastapi_cache"]
295
+ Lifespan["lifespan: start/stop sweeper"]
296
+ end
297
+
298
+ subgraph core [Zero-Dependency Core]
299
+ KeyBuilder["make_cache_key()"]
300
+ Store["MemoryStore"]
301
+ Singleflight["PerKeySingleflight"]
302
+ Sweeper["ExpirySweeper"]
303
+ end
304
+
305
+ Decorator --> KeyBuilder
306
+ Decorator --> Store
307
+ Decorator --> Singleflight
308
+ Lifespan --> Sweeper
309
+ Sweeper --> Store
310
+ Store -->|"OrderedDict + TTL entries"| Memory[(In-Process Memory)]
311
+ ```
312
+
313
+ ## Core API
314
+
315
+ The core package has no runtime dependencies. Import from `inhouse` directly:
316
+
317
+ ```python
318
+ from inhouse import MemoryStore, configure_default_store, inhouse_cache, make_cache_key
319
+ ```
320
+
321
+ See [Configuration reference](#configuration-reference) for full decorator and store options.
322
+
323
+ ## License
324
+
325
+ MIT
@@ -0,0 +1,293 @@
1
+ # inhouse
2
+
3
+ **Zero-dependency, in-process TTL cache for Python.** One decorator, stampede-safe, LRU-bounded. For when Redis is a meeting you don't want to have, or when you need to avoid yet another deployment. Designed to be simple and effective without bloat or complexity for developers.
4
+
5
+ Designed for easy use with FastAPI applications. Although FastAPI integration is absolutely optional.
6
+
7
+ ## Install
8
+
9
+ The package is published on PyPI as **`inhouse-cache`**. Imports use `inhouse` (e.g. `from inhouse import MemoryStore`).
10
+
11
+ Core:
12
+ ```bash
13
+ pip install inhouse-cache
14
+ ```
15
+
16
+ With FastAPI helpers (`fastapi_cache`, lifespan sweeper):
17
+ ```bash
18
+ pip install inhouse-cache[fastapi]
19
+ ```
20
+
21
+ ## Quick start Usage
22
+
23
+ ### Core (any Python project)
24
+
25
+ ```python
26
+ from inhouse import MemoryStore, inhouse_cache
27
+
28
+ store = MemoryStore(max_size=1024, default_ttl=60)
29
+
30
+
31
+ @inhouse_cache(store=store)
32
+ async def load_user(user_id: int) -> dict[str, int]:
33
+ return {"user_id": user_id}
34
+ ```
35
+
36
+ Works with both `async def` and `def` callables.
37
+
38
+ ### FastAPI Use Case
39
+
40
+ ```python
41
+ import asyncio
42
+
43
+ from fastapi import FastAPI
44
+
45
+ from inhouse import MemoryStore
46
+ from inhouse.fastapi import create_lifespan, fastapi_cache
47
+
48
+ store = MemoryStore(max_size=1024, default_ttl=60)
49
+ app = FastAPI(lifespan=create_lifespan(store))
50
+
51
+
52
+ @app.get("/items/{item_id}")
53
+ @fastapi_cache(store=store)
54
+ async def get_item(item_id: int) -> dict[str, int]:
55
+ await asyncio.sleep(0.1) # expensive work
56
+ return {"item_id": item_id}
57
+ ```
58
+
59
+ Requires `pip install inhouse-cache[fastapi]`.
60
+
61
+ ## Features
62
+
63
+ **Core (zero dependencies)**
64
+
65
+ - TTL cache with lazy expiry on read
66
+ - LRU eviction when `max_size` is exceeded
67
+ - Per-key singleflight stampede guard - concurrent misses on the same key coalesce to one computation. Errors and cancellations propagate to all waiters (no hung followers on shutdown)
68
+ - Deterministic cache keys - canonical JSON serialization. Keyword argument order and Request subclasses don't cause spurious cache misses
69
+ - Thread-safe store for sync and async callables
70
+ - Fixed, store-default, or callable TTL on each cache write
71
+
72
+ **Optional FastAPI extra** (`pip install inhouse-cache[fastapi]`)
73
+
74
+ - `@fastapi_cache` with Request/Response-aware cache keys
75
+ - Background expiry sweeper via FastAPI lifespan helpers
76
+ - Clean lifespan shutdown - background sweeper cancels without noisy tracebacks
77
+
78
+ ## Configuration reference
79
+
80
+ ### `MemoryStore`
81
+
82
+ ```python
83
+ from inhouse import MemoryStore
84
+
85
+ store = MemoryStore(max_size=1024, default_ttl=60)
86
+ ```
87
+
88
+ | Parameter / attribute | Type | Default | Description |
89
+ |---|---|---|---|
90
+ | `max_size` | `int` | `1024` | Maximum number of entries before LRU eviction |
91
+ | `default_ttl` | `float \| None` | `None` | Default TTL in seconds for `store.set()` and decorators that omit `ttl_seconds` |
92
+ | `default_ttl` (property) | `float \| None` | — | Mutable at runtime; affects **future** writes only |
93
+ | `size` | `int` (read-only) | — | Current number of cached entries |
94
+
95
+ Store methods:
96
+
97
+ | Method | Description |
98
+ |---|---|
99
+ | `get(key, *, default=MISS)` | Return a cached value, or `default` on miss/expiry |
100
+ | `set(key, value, ttl_seconds=None)` | Write a value; uses `default_ttl` when `ttl_seconds` is omitted |
101
+ | `delete(key)` | Remove one entry |
102
+ | `clear()` | Remove all entries |
103
+ | `purge_expired()` | Proactively delete expired entries |
104
+ | `keys()` | List current cache keys |
105
+
106
+ ### `@inhouse_cache` / `cache()`
107
+
108
+ Core decorator. Works with both `async def` and `def` callables.
109
+
110
+ ```python
111
+ from inhouse import MemoryStore, inhouse_cache, make_cache_key
112
+
113
+ store = MemoryStore(default_ttl=60)
114
+
115
+ @inhouse_cache(
116
+ ttl_seconds=60, # optional — see Dynamic TTL below
117
+ store=store, # optional — defaults to a module-level store
118
+ key_builder=make_cache_key, # optional — custom cache key strategy
119
+ exclude_types=(object,), # optional — types omitted from key material
120
+ )
121
+ async def load_user(user_id: int) -> dict[str, int]:
122
+ return {"user_id": user_id}
123
+ ```
124
+
125
+ | Parameter | Type | Default | Description |
126
+ |---|---|---|---|
127
+ | `ttl_seconds` | `float \| Callable[[], float] \| None` | `None` | TTL in seconds for each cache write. See [Dynamic TTL](#dynamic-ttl). |
128
+ | `store` | `MemoryStore \| None` | module default | Cache instance to read/write |
129
+ | `key_builder` | `Callable[..., str]` | `make_cache_key` | Builds the cache key from function identity + arguments |
130
+ | `exclude_types` | `tuple[type, ...]` | `()` | Argument types excluded from key material (e.g. request objects) |
131
+
132
+ `inhouse_cache` is an alias for `cache`.
133
+
134
+ Global default store helpers:
135
+
136
+ ```python
137
+ from inhouse import configure_default_store, get_default_store
138
+
139
+ store = MemoryStore(default_ttl=120)
140
+ configure_default_store(store)
141
+
142
+ @inhouse_cache() # uses the configured default store + its default_ttl
143
+ async def load_config() -> dict[str, str]:
144
+ ...
145
+ ```
146
+
147
+ ### `@fastapi_cache` *(optional — requires `inhouse-cache[fastapi]`)*
148
+
149
+ FastAPI-friendly wrapper around `inhouse_cache`. Automatically excludes Starlette `Request` and `Response` objects from cache keys.
150
+
151
+ ```python
152
+ from inhouse.fastapi import create_lifespan, fastapi_cache
153
+
154
+ store = MemoryStore(max_size=512, default_ttl=60)
155
+ app = FastAPI(lifespan=create_lifespan(store, sweep_interval=30.0))
156
+
157
+ @app.get("/items/{item_id}")
158
+ @fastapi_cache(store=store)
159
+ async def get_item(item_id: int) -> dict[str, int]:
160
+ ...
161
+ ```
162
+
163
+ | Parameter | Type | Default | Description |
164
+ |---|---|---|---|
165
+ | `ttl_seconds` | `float \| Callable[[], float] \| None` | `None` | Same semantics as `@inhouse_cache` |
166
+ | `store` | `MemoryStore \| None` | module default | Cache instance to read/write |
167
+
168
+ `fastapi_cache` does not expose `key_builder` or `exclude_types`; it always uses the FastAPI-aware key builder.
169
+
170
+ ### Lifespan / background cleanup *(optional — requires `inhouse-cache[fastapi]`)*
171
+
172
+ ```python
173
+ from inhouse.fastapi import create_lifespan, inhouse_lifespan
174
+
175
+ # Option A: pass directly to FastAPI
176
+ app = FastAPI(lifespan=create_lifespan(store, sweep_interval=30.0))
177
+
178
+ # Option B: use inside your own lifespan
179
+ async with inhouse_lifespan(store, sweep_interval=30.0):
180
+ ...
181
+ ```
182
+
183
+ | Parameter | Type | Default | Description |
184
+ |---|---|---|---|
185
+ | `store` | `MemoryStore` | required | Store to sweep for expired entries |
186
+ | `sweep_interval` | `float` | `30.0` | Seconds between background purge runs |
187
+
188
+ ## Dynamic TTL
189
+
190
+ TTL is resolved when a value is **written** to the cache (on a miss), not on every read. Changing TTL settings does not retroactively extend entries already stored.
191
+
192
+ Three ways to configure expiration:
193
+
194
+ ### 1. Fixed TTL (per route)
195
+
196
+ ```python
197
+ @inhouse_cache(60, store=store)
198
+ async def load_user(user_id: int) -> dict[str, int]:
199
+ ...
200
+ ```
201
+
202
+ Always expires 60 seconds after the value is cached.
203
+
204
+ ### 2. Store default (mutable at runtime)
205
+
206
+ ```python
207
+ store = MemoryStore(default_ttl=60)
208
+
209
+ @inhouse_cache(store=store)
210
+ async def load_config() -> dict[str, str]:
211
+ ...
212
+
213
+ # Later - affects future cache writes only
214
+ store.default_ttl = 300
215
+ ```
216
+
217
+ Omitting `ttl_seconds` on the decorator uses `store.default_ttl`. If both are missing, inhouse raises `ValueError`.
218
+
219
+ `store.default_ttl` is safe to change at runtime from other threads; new writes pick up the updated value atomically.
220
+
221
+ ### 3. Callable TTL (evaluated on each write)
222
+
223
+ ```python
224
+ settings = {"cache_ttl": 60}
225
+
226
+ @inhouse_cache(lambda: settings["cache_ttl"], store=store)
227
+ async def load_dashboard() -> dict[str, str]:
228
+ ...
229
+
230
+ settings["cache_ttl"] = 300 # next cache miss uses 300 seconds
231
+ ```
232
+
233
+ Useful for feature flags, config files, or environment-driven TTL without redeploying.
234
+
235
+ ### Priority order
236
+
237
+ When a cache miss is written, TTL is resolved as:
238
+
239
+ 1. Callable `ttl_seconds()` result, if a callable was passed
240
+ 2. Fixed `ttl_seconds` float, if provided
241
+ 3. `store.default_ttl`, if set
242
+ 4. Otherwise → `ValueError`
243
+
244
+ ## When to use inhouse
245
+
246
+ | Scenario | inhouse | Redis | fastapi-cache2 |
247
+ |---|---|---|---|
248
+ | Single-node FastAPI prototype | Great | Overkill | Great |
249
+ | Zero external infrastructure | Yes | No | Depends on backend |
250
+ | Distributed multi-instance cache | No | Yes | Yes (with Redis) |
251
+ | Decorator-first developer UX | Yes | No | Yes |
252
+
253
+ ## Important limitations
254
+
255
+ inhouse is **per-process** memory. If you run `uvicorn main:app --workers 4`, each worker maintains its own independent cache. That keeps the design simple and avoids shared infrastructure. It is not a distributed cache.
256
+
257
+ ## Architecture
258
+
259
+ ```mermaid
260
+ flowchart TB
261
+ subgraph fastapi_layer [Optional FastAPI Layer]
262
+ Decorator["@fastapi_cache"]
263
+ Lifespan["lifespan: start/stop sweeper"]
264
+ end
265
+
266
+ subgraph core [Zero-Dependency Core]
267
+ KeyBuilder["make_cache_key()"]
268
+ Store["MemoryStore"]
269
+ Singleflight["PerKeySingleflight"]
270
+ Sweeper["ExpirySweeper"]
271
+ end
272
+
273
+ Decorator --> KeyBuilder
274
+ Decorator --> Store
275
+ Decorator --> Singleflight
276
+ Lifespan --> Sweeper
277
+ Sweeper --> Store
278
+ Store -->|"OrderedDict + TTL entries"| Memory[(In-Process Memory)]
279
+ ```
280
+
281
+ ## Core API
282
+
283
+ The core package has no runtime dependencies. Import from `inhouse` directly:
284
+
285
+ ```python
286
+ from inhouse import MemoryStore, configure_default_store, inhouse_cache, make_cache_key
287
+ ```
288
+
289
+ See [Configuration reference](#configuration-reference) for full decorator and store options.
290
+
291
+ ## License
292
+
293
+ MIT
@@ -0,0 +1,71 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "inhouse-cache"
7
+ version = "0.1.1"
8
+ description = "Zero-dependency, in-process TTL cache for Python. Optional FastAPI decorators, stampede-safe, LRU-bounded."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Michael Mooney", email = "inhouse@rancero.com" }]
13
+ keywords = ["cache", "fastapi", "ttl", "lru", "async"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Framework :: FastAPI",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.optional-dependencies]
28
+ fastapi = ["fastapi>=0.100"]
29
+ dev = [
30
+ "fastapi>=0.100",
31
+ "httpx>=0.27",
32
+ "mypy>=1.10",
33
+ "pytest>=8.0",
34
+ "pytest-asyncio>=0.23",
35
+ "ruff>=0.4",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/kineticquant/inhouse"
40
+ Repository = "https://github.com/kineticquant/inhouse"
41
+ Issues = "https://github.com/kineticquant/inhouse/issues"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/inhouse"]
45
+
46
+ [tool.hatch.build.targets.wheel.sources]
47
+ "src/inhouse" = "inhouse"
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = ["src/inhouse", "README.md", "LICENSE"]
51
+
52
+ [tool.pytest.ini_options]
53
+ asyncio_mode = "auto"
54
+ testpaths = ["tests"]
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py310"
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "I", "UP"]
62
+
63
+ [tool.mypy]
64
+ python_version = "3.10"
65
+ strict = true
66
+ warn_return_any = true
67
+ warn_unused_configs = true
68
+
69
+ [[tool.mypy.overrides]]
70
+ module = "tests.*"
71
+ disallow_untyped_defs = false
@@ -0,0 +1,19 @@
1
+ """inhouse — zero-dependency, in-process TTL cache with LRU eviction."""
2
+
3
+ from inhouse.decorator import cache, configure_default_store, get_default_store, inhouse_cache
4
+ from inhouse.entry import CacheEntry
5
+ from inhouse.keys import make_cache_key
6
+ from inhouse.store import MemoryStore
7
+ from inhouse.sweeper import ExpirySweeper
8
+
9
+ __all__ = [
10
+ "CacheEntry",
11
+ "ExpirySweeper",
12
+ "MemoryStore",
13
+ "cache",
14
+ "configure_default_store",
15
+ "get_default_store",
16
+ "inhouse_cache",
17
+ "make_cache_key",
18
+ ]
19
+ __version__ = "0.1.1"
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+ from functools import wraps
6
+ from typing import Any, TypeVar
7
+
8
+ from inhouse.keys import make_cache_key
9
+ from inhouse.singleflight import AsyncSingleflight, SyncSingleflight
10
+ from inhouse.store import MISS, MemoryStore
11
+
12
+ F = TypeVar("F", bound=Callable[..., Any])
13
+
14
+ TtlSource = float | Callable[[], float] | None
15
+
16
+ _DEFAULT_STORE = MemoryStore()
17
+ _ASYNC_SINGLEFLIGHT = AsyncSingleflight()
18
+ _SYNC_SINGLEFLIGHT = SyncSingleflight()
19
+
20
+
21
+ def get_default_store() -> MemoryStore:
22
+ return _DEFAULT_STORE
23
+
24
+
25
+ def configure_default_store(store: MemoryStore) -> None:
26
+ global _DEFAULT_STORE
27
+ _DEFAULT_STORE = store
28
+
29
+
30
+ def _resolve_ttl(target_store: MemoryStore, ttl_seconds: TtlSource) -> float:
31
+ if callable(ttl_seconds):
32
+ resolved = ttl_seconds()
33
+ elif ttl_seconds is not None:
34
+ resolved = ttl_seconds
35
+ elif target_store.default_ttl is not None:
36
+ resolved = target_store.default_ttl
37
+ else:
38
+ raise ValueError("ttl_seconds is required when the store has no default_ttl")
39
+
40
+ if resolved <= 0:
41
+ raise ValueError("ttl_seconds must be positive")
42
+ return resolved
43
+
44
+
45
+ def cache(
46
+ ttl_seconds: TtlSource = None,
47
+ *,
48
+ store: MemoryStore | None = None,
49
+ key_builder: Callable[..., str] = make_cache_key,
50
+ exclude_types: tuple[type[Any], ...] = (),
51
+ ) -> Callable[[F], F]:
52
+ """Cache decorator for sync and async callables."""
53
+
54
+ def decorator(func: F) -> F:
55
+ if inspect.iscoroutinefunction(func):
56
+
57
+ @wraps(func)
58
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
59
+ target_store = store or _DEFAULT_STORE
60
+ cache_key = key_builder(
61
+ func,
62
+ args,
63
+ kwargs,
64
+ exclude_types=exclude_types,
65
+ )
66
+
67
+ cached = target_store.get(cache_key)
68
+ if cached is not MISS:
69
+ return cached
70
+
71
+ async def compute() -> Any:
72
+ recheck = target_store.get(cache_key)
73
+ if recheck is not MISS:
74
+ return recheck
75
+ result = await func(*args, **kwargs)
76
+ target_store.set(cache_key, result, _resolve_ttl(target_store, ttl_seconds))
77
+ return result
78
+
79
+ return await _ASYNC_SINGLEFLIGHT.do(cache_key, compute)
80
+
81
+ return async_wrapper # type: ignore[return-value]
82
+
83
+ @wraps(func)
84
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
85
+ target_store = store or _DEFAULT_STORE
86
+ cache_key = key_builder(
87
+ func,
88
+ args,
89
+ kwargs,
90
+ exclude_types=exclude_types,
91
+ )
92
+
93
+ cached = target_store.get(cache_key)
94
+ if cached is not MISS:
95
+ return cached
96
+
97
+ def compute() -> Any:
98
+ recheck = target_store.get(cache_key)
99
+ if recheck is not MISS:
100
+ return recheck
101
+ result = func(*args, **kwargs)
102
+ target_store.set(cache_key, result, _resolve_ttl(target_store, ttl_seconds))
103
+ return result
104
+
105
+ return _SYNC_SINGLEFLIGHT.do(cache_key, compute)
106
+
107
+ return sync_wrapper # type: ignore[return-value]
108
+
109
+ return decorator
110
+
111
+
112
+ inhouse_cache = cache
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class CacheEntry:
9
+ expires_at: float
10
+ value: Any
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator, Callable
4
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
5
+ from typing import Any
6
+
7
+ from inhouse.decorator import cache, inhouse_cache
8
+ from inhouse.keys import make_cache_key
9
+ from inhouse.store import MemoryStore
10
+ from inhouse.sweeper import ExpirySweeper
11
+
12
+ # MUST have fastapi which uses starlette to work with RR tuple
13
+ try:
14
+ from starlette.requests import Request
15
+ from starlette.responses import Response
16
+
17
+ _FASTAPI_EXCLUDE_TYPES: tuple[type[Any], ...] = (Request, Response)
18
+ except ImportError: # pragma: no cover - optional dependency, technically
19
+ _FASTAPI_EXCLUDE_TYPES = ()
20
+
21
+
22
+ def make_fastapi_cache_key(
23
+ func: Any,
24
+ args: Any,
25
+ kwargs: Any,
26
+ *,
27
+ exclude_types: tuple[type[Any], ...] = (),
28
+ ) -> str:
29
+ merged_exclude = _FASTAPI_EXCLUDE_TYPES + exclude_types
30
+ return make_cache_key(func, args, kwargs, exclude_types=merged_exclude)
31
+
32
+
33
+ def fastapi_cache(
34
+ ttl_seconds: float | Callable[[], float] | None = None,
35
+ *,
36
+ store: MemoryStore | None = None,
37
+ ) -> Callable[[Any], Any]:
38
+ """Cache decorator that excludes Starlette Request/Response objects from keys."""
39
+ return inhouse_cache(
40
+ ttl_seconds,
41
+ store=store,
42
+ key_builder=make_fastapi_cache_key,
43
+ )
44
+
45
+
46
+ @asynccontextmanager
47
+ async def inhouse_lifespan(
48
+ store: MemoryStore,
49
+ *,
50
+ sweep_interval: float = 30.0,
51
+ ) -> AsyncIterator[None]:
52
+ """FastAPI lifespan helper that starts and stops the expiry sweeper."""
53
+ sweeper = ExpirySweeper(store, interval_seconds=sweep_interval)
54
+ task = sweeper.start()
55
+ try:
56
+ yield
57
+ finally:
58
+ await sweeper.stop(task)
59
+
60
+
61
+ def create_lifespan(
62
+ store: MemoryStore,
63
+ *,
64
+ sweep_interval: float = 30.0,
65
+ ) -> Callable[[Any], AbstractAsyncContextManager[None]]:
66
+ """Return a FastAPI-compatible lifespan callable bound to a cache store."""
67
+
68
+ @asynccontextmanager
69
+ async def lifespan(_app: Any) -> AsyncIterator[None]:
70
+ async with inhouse_lifespan(store, sweep_interval=sweep_interval):
71
+ yield
72
+
73
+ return lifespan
74
+
75
+
76
+ __all__ = [
77
+ "cache",
78
+ "create_lifespan",
79
+ "fastapi_cache",
80
+ "inhouse_cache",
81
+ "inhouse_lifespan",
82
+ "make_fastapi_cache_key",
83
+ ]
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from typing import Any
7
+
8
+
9
+ def _normalize_value(value: Any) -> Any:
10
+ if isinstance(value, Mapping):
11
+ return {
12
+ str(k): _normalize_value(v)
13
+ for k, v in sorted(value.items(), key=lambda item: str(item[0]))
14
+ }
15
+ if isinstance(value, list | tuple):
16
+ return [_normalize_value(item) for item in value]
17
+ if isinstance(value, str | int | float | bool) or value is None:
18
+ return value
19
+ return str(value)
20
+
21
+
22
+ def _collect_key_material(
23
+ args: Sequence[Any],
24
+ kwargs: Mapping[str, Any],
25
+ exclude_types: tuple[type[Any], ...],
26
+ ) -> dict[str, Any]:
27
+ filtered_args = [arg for arg in args if not isinstance(arg, exclude_types)]
28
+ filtered_kwargs = {
29
+ key: value for key, value in sorted(kwargs.items()) if not isinstance(value, exclude_types)
30
+ }
31
+ return {
32
+ "args": [_normalize_value(arg) for arg in filtered_args],
33
+ "kwargs": {key: _normalize_value(value) for key, value in filtered_kwargs.items()},
34
+ }
35
+
36
+
37
+ def make_cache_key(
38
+ func: Callable[..., Any],
39
+ args: Sequence[Any],
40
+ kwargs: Mapping[str, Any],
41
+ *,
42
+ exclude_types: tuple[type[Any], ...] = (),
43
+ ) -> str:
44
+ """Build a deterministic cache key from function identity and call arguments."""
45
+ material = _collect_key_material(args, kwargs, exclude_types)
46
+ payload = json.dumps(material, sort_keys=True, separators=(",", ":"))
47
+ digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
48
+ return f"{func.__module__}.{func.__qualname__}:{digest}"
File without changes
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import threading
5
+ from collections.abc import Awaitable, Callable
6
+ from concurrent.futures import Future
7
+ from typing import Any, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ def _release_async_waiters(future: asyncio.Future[Any], exc: BaseException) -> None:
13
+ if future.done():
14
+ return
15
+ if isinstance(exc, asyncio.CancelledError):
16
+ future.cancel()
17
+ else:
18
+ future.set_exception(exc)
19
+
20
+
21
+ def _release_sync_waiters(future: Future[Any], exc: BaseException) -> None:
22
+ if not future.done():
23
+ future.set_exception(exc)
24
+
25
+
26
+ class AsyncSingleflight:
27
+ """Coalesce concurrent async computations for the same cache key."""
28
+
29
+ def __init__(self) -> None:
30
+ self._inflight: dict[str, asyncio.Future[Any]] = {}
31
+ self._guard = asyncio.Lock()
32
+
33
+ async def do(self, key: str, compute: Callable[[], Awaitable[T]]) -> T:
34
+ async with self._guard:
35
+ future = self._inflight.get(key)
36
+ if future is None:
37
+ loop = asyncio.get_running_loop()
38
+ future = loop.create_future()
39
+ self._inflight[key] = future
40
+ leader = True
41
+ else:
42
+ leader = False
43
+
44
+ if not leader:
45
+ result: T = await future
46
+ return result
47
+
48
+ try:
49
+ result = await compute()
50
+ except BaseException as exc:
51
+ _release_async_waiters(future, exc)
52
+ raise
53
+ else:
54
+ if not future.done():
55
+ future.set_result(result)
56
+ return result
57
+ finally:
58
+ async with self._guard:
59
+ self._inflight.pop(key, None)
60
+
61
+
62
+ class SyncSingleflight:
63
+ """Coalesce concurrent sync computations for the same cache key."""
64
+
65
+ def __init__(self) -> None:
66
+ self._inflight: dict[str, Future[Any]] = {}
67
+ self._guard = threading.Lock()
68
+
69
+ def do(self, key: str, compute: Callable[[], T]) -> T:
70
+ with self._guard:
71
+ future = self._inflight.get(key)
72
+ if future is None:
73
+ future = Future()
74
+ self._inflight[key] = future
75
+ leader = True
76
+ else:
77
+ leader = False
78
+
79
+ if not leader:
80
+ result: T = future.result()
81
+ return result
82
+
83
+ try:
84
+ result = compute()
85
+ except BaseException as exc:
86
+ _release_sync_waiters(future, exc)
87
+ raise
88
+ else:
89
+ future.set_result(result)
90
+ return result
91
+ finally:
92
+ with self._guard:
93
+ self._inflight.pop(key, None)
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections import OrderedDict
6
+ from typing import Any
7
+
8
+ from inhouse.entry import CacheEntry
9
+
10
+
11
+ class _CacheMiss:
12
+ """Sentinel returned when a key is absent or expired."""
13
+
14
+
15
+ MISS = _CacheMiss()
16
+
17
+
18
+ class MemoryStore:
19
+ """Thread-safe in-memory cache with TTL expiry and LRU eviction."""
20
+
21
+ def __init__(self, max_size: int = 1024, *, default_ttl: float | None = None) -> None:
22
+ if max_size < 1:
23
+ raise ValueError("max_size must be at least 1")
24
+ self._max_size = max_size
25
+ self._default_ttl = default_ttl
26
+ self._entries: OrderedDict[str, CacheEntry] = OrderedDict()
27
+ self._lock = threading.RLock()
28
+ if default_ttl is not None and default_ttl <= 0:
29
+ raise ValueError("default_ttl must be positive")
30
+
31
+ @property
32
+ def max_size(self) -> int:
33
+ return self._max_size
34
+
35
+ @property
36
+ def default_ttl(self) -> float | None:
37
+ with self._lock:
38
+ return self._default_ttl
39
+
40
+ @default_ttl.setter
41
+ def default_ttl(self, value: float | None) -> None:
42
+ if value is not None and value <= 0:
43
+ raise ValueError("default_ttl must be positive")
44
+ with self._lock:
45
+ self._default_ttl = value
46
+
47
+ @property
48
+ def size(self) -> int:
49
+ with self._lock:
50
+ return len(self._entries)
51
+
52
+ def get(self, key: str, *, default: Any = MISS) -> Any:
53
+ with self._lock:
54
+ entry = self._entries.get(key)
55
+ if entry is None:
56
+ return default
57
+ if time.monotonic() >= entry.expires_at:
58
+ del self._entries[key]
59
+ return default
60
+ self._entries.move_to_end(key)
61
+ return entry.value
62
+
63
+ def set(self, key: str, value: Any, ttl_seconds: float | None = None) -> None:
64
+ with self._lock:
65
+ ttl = ttl_seconds if ttl_seconds is not None else self._default_ttl
66
+ if ttl is None or ttl <= 0:
67
+ raise ValueError("ttl_seconds must be positive")
68
+ expires_at = time.monotonic() + ttl
69
+ self._entries[key] = CacheEntry(expires_at=expires_at, value=value)
70
+ self._entries.move_to_end(key)
71
+ while len(self._entries) > self._max_size:
72
+ self._entries.popitem(last=False)
73
+
74
+ def delete(self, key: str) -> bool:
75
+ with self._lock:
76
+ if key in self._entries:
77
+ del self._entries[key]
78
+ return True
79
+ return False
80
+
81
+ def clear(self) -> None:
82
+ with self._lock:
83
+ self._entries.clear()
84
+
85
+ def purge_expired(self) -> int:
86
+ """Remove all expired entries. Returns count of removed keys."""
87
+ now = time.monotonic()
88
+ removed = 0
89
+ with self._lock:
90
+ expired_keys = [
91
+ key for key, entry in self._entries.items() if now >= entry.expires_at
92
+ ]
93
+ for key in expired_keys:
94
+ del self._entries[key]
95
+ removed += 1
96
+ return removed
97
+
98
+ def keys(self) -> list[str]:
99
+ with self._lock:
100
+ return list(self._entries.keys())
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from inhouse.store import MemoryStore
6
+
7
+
8
+ class ExpirySweeper:
9
+ """Background task that periodically purges expired cache entries."""
10
+
11
+ def __init__(self, store: MemoryStore, *, interval_seconds: float = 30.0) -> None:
12
+ if interval_seconds <= 0:
13
+ raise ValueError("interval_seconds must be positive")
14
+ self._store = store
15
+ self._interval_seconds = interval_seconds
16
+ self._task: asyncio.Task[None] | None = None
17
+
18
+ async def run(self) -> None:
19
+ while True:
20
+ await asyncio.sleep(self._interval_seconds)
21
+ self._store.purge_expired()
22
+
23
+ def start(self) -> asyncio.Task[None]:
24
+ self._task = asyncio.create_task(self.run(), name="inhouse-expiry-sweeper")
25
+ return self._task
26
+
27
+ async def stop(self, task: asyncio.Task[None] | None = None) -> None:
28
+ target = task or self._task
29
+ if target is None:
30
+ return
31
+ target.cancel()
32
+ try:
33
+ await target
34
+ except asyncio.CancelledError:
35
+ pass
36
+ if task is None:
37
+ self._task = None