inhouse-cache 0.1.1__py3-none-any.whl

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.
inhouse/__init__.py ADDED
@@ -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"
inhouse/decorator.py ADDED
@@ -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
inhouse/entry.py ADDED
@@ -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
inhouse/fastapi.py ADDED
@@ -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
+ ]
inhouse/keys.py ADDED
@@ -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}"
inhouse/py.typed ADDED
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)
inhouse/store.py ADDED
@@ -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())
inhouse/sweeper.py ADDED
@@ -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
@@ -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,13 @@
1
+ inhouse/__init__.py,sha256=R7J36K8n_moMUDYE9sIeSSJ_I3uTPfYPq19nD1XWReE,535
2
+ inhouse/decorator.py,sha256=7FWqZCec913vomtfobHOqRmzivESTFozHBrHTEUANTA,3427
3
+ inhouse/entry.py,sha256=ob1-TeOF8fFjvrLq1sEEXxm0z60wJp53twyICGTTxYA,173
4
+ inhouse/fastapi.py,sha256=hErI0rsuvvcpriX0zWVlPJP7rWbskiHqWBngV1SIXbA,2279
5
+ inhouse/keys.py,sha256=S8f3RHgA8bE1WdWphyp5RzBasZtn43upqNzOj07CX1U,1632
6
+ inhouse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ inhouse/singleflight.py,sha256=Zp2WRFPFc6V_G9Nqw_ey69R5APIzEOazDPy8RCsDHGw,2623
8
+ inhouse/store.py,sha256=xXU8IJmXZJHTOAFZuC9vUJMTeoTsd0Img1iRMfKi6K4,3175
9
+ inhouse/sweeper.py,sha256=eMnU0d82ASOB-4S-ZcEH7iCJ2DSn-sK_a9LRKBrRt0U,1140
10
+ inhouse_cache-0.1.1.dist-info/METADATA,sha256=BF_vNeetOeY2f85-HLJ0SfI2kGRwy-SSTCT9h9lo_pI,10637
11
+ inhouse_cache-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ inhouse_cache-0.1.1.dist-info/licenses/LICENSE,sha256=TN2actEvN1rymIGBiD7QgTPpYwuiDwbejilCqrhp2Yc,1069
13
+ inhouse_cache-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.