Skip to content

Commit 6aa6e59

Browse files
authored
Added doctests to utility modules (#400)
Signed-off-by: Manav Gupta <[email protected]>
1 parent be388b9 commit 6aa6e59

File tree

7 files changed

+421
-29
lines changed

7 files changed

+421
-29
lines changed

mcpgateway/cache/resource_cache.py

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,29 @@
1010
- TTL-based expiration
1111
- Maximum size limit with LRU eviction
1212
- Thread-safe operations
13+
14+
Doctest examples
15+
----------------
16+
>>> from mcpgateway.cache.resource_cache import ResourceCache
17+
>>> cache = ResourceCache(max_size=2, ttl=1)
18+
>>> cache.set('a', 1)
19+
>>> cache.get('a')
20+
1
21+
>>> import time
22+
>>> time.sleep(1.1)
23+
>>> cache.get('a') is None
24+
True
25+
>>> cache.set('a', 1)
26+
>>> cache.set('b', 2)
27+
>>> cache.set('c', 3) # LRU eviction
28+
>>> sorted(cache._cache.keys())
29+
['b', 'c']
30+
>>> cache.delete('b')
31+
>>> cache.get('b') is None
32+
True
33+
>>> cache.clear()
34+
>>> cache.get('a') is None
35+
True
1336
"""
1437

1538
# Standard
@@ -32,13 +55,36 @@ class CacheEntry:
3255

3356

3457
class ResourceCache:
35-
"""Resource content cache with TTL expiration.
58+
"""
59+
Resource content cache with TTL expiration.
3660
3761
Attributes:
3862
max_size: Maximum number of entries
3963
ttl: Time-to-live in seconds
4064
_cache: Cache storage
4165
_lock: Async lock for thread safety
66+
67+
Doctest:
68+
>>> from mcpgateway.cache.resource_cache import ResourceCache
69+
>>> cache = ResourceCache(max_size=2, ttl=1)
70+
>>> cache.set('a', 1)
71+
>>> cache.get('a')
72+
1
73+
>>> import time
74+
>>> time.sleep(1.1)
75+
>>> cache.get('a') is None
76+
True
77+
>>> cache.set('a', 1)
78+
>>> cache.set('b', 2)
79+
>>> cache.set('c', 3) # LRU eviction
80+
>>> sorted(cache._cache.keys())
81+
['b', 'c']
82+
>>> cache.delete('b')
83+
>>> cache.get('b') is None
84+
True
85+
>>> cache.clear()
86+
>>> cache.get('a') is None
87+
True
4288
"""
4389

4490
def __init__(self, max_size: int = 1000, ttl: int = 3600):
@@ -65,13 +111,25 @@ async def shutdown(self) -> None:
65111
self.clear()
66112

67113
def get(self, key: str) -> Optional[Any]:
68-
"""Get value from cache.
114+
"""
115+
Get value from cache.
69116
70117
Args:
71118
key: Cache key
72119
73120
Returns:
74121
Cached value or None if not found/expired
122+
123+
Doctest:
124+
>>> from mcpgateway.cache.resource_cache import ResourceCache
125+
>>> cache = ResourceCache(max_size=2, ttl=1)
126+
>>> cache.set('a', 1)
127+
>>> cache.get('a')
128+
1
129+
>>> import time
130+
>>> time.sleep(1.1)
131+
>>> cache.get('a') is None
132+
True
75133
"""
76134
if key not in self._cache:
77135
return None
@@ -89,11 +147,19 @@ def get(self, key: str) -> Optional[Any]:
89147
return entry.value
90148

91149
def set(self, key: str, value: Any) -> None:
92-
"""Set value in cache.
150+
"""
151+
Set value in cache.
93152
94153
Args:
95154
key: Cache key
96155
value: Value to cache
156+
157+
Doctest:
158+
>>> from mcpgateway.cache.resource_cache import ResourceCache
159+
>>> cache = ResourceCache(max_size=2, ttl=1)
160+
>>> cache.set('a', 1)
161+
>>> cache.get('a')
162+
1
97163
"""
98164
now = time.time()
99165

@@ -107,15 +173,34 @@ def set(self, key: str, value: Any) -> None:
107173
self._cache[key] = CacheEntry(value=value, expires_at=now + self.ttl, last_access=now)
108174

109175
def delete(self, key: str) -> None:
110-
"""Delete value from cache.
176+
"""
177+
Delete value from cache.
111178
112179
Args:
113180
key: Cache key to delete
181+
182+
Doctest:
183+
>>> from mcpgateway.cache.resource_cache import ResourceCache
184+
>>> cache = ResourceCache()
185+
>>> cache.set('a', 1)
186+
>>> cache.delete('a')
187+
>>> cache.get('a') is None
188+
True
114189
"""
115190
self._cache.pop(key, None)
116191

117192
def clear(self) -> None:
118-
"""Clear all cached entries."""
193+
"""
194+
Clear all cached entries.
195+
196+
Doctest:
197+
>>> from mcpgateway.cache.resource_cache import ResourceCache
198+
>>> cache = ResourceCache()
199+
>>> cache.set('a', 1)
200+
>>> cache.clear()
201+
>>> cache.get('a') is None
202+
True
203+
"""
119204
self._cache.clear()
120205

121206
async def _cleanup_loop(self) -> None:

mcpgateway/cache/session_registry.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@
77
88
This module provides a registry for SSE sessions with support for distributed deployment
99
using Redis or SQLAlchemy as optional backends for shared state between workers.
10+
11+
Doctest examples (memory backend only)
12+
--------------------------------------
13+
>>> from mcpgateway.cache.session_registry import SessionRegistry
14+
>>> class DummyTransport:
15+
... pass
16+
>>> reg = SessionRegistry(backend='memory')
17+
>>> import asyncio
18+
>>> asyncio.run(reg.add_session('sid', DummyTransport()))
19+
>>> t = asyncio.run(reg.get_session('sid'))
20+
>>> isinstance(t, DummyTransport)
21+
True
22+
>>> asyncio.run(reg.remove_session('sid'))
23+
>>> asyncio.run(reg.get_session('sid')) is None
24+
True
1025
"""
1126

1227
# Standard
@@ -106,7 +121,8 @@ def __init__(
106121

107122

108123
class SessionRegistry(SessionBackend):
109-
"""Registry for SSE sessions with optional distributed state.
124+
"""
125+
Registry for SSE sessions with optional distributed state.
110126
111127
Supports three backend modes:
112128
- memory: In-memory storage (default, no dependencies)
@@ -115,6 +131,20 @@ class SessionRegistry(SessionBackend):
115131
116132
In distributed mode (redis/database), session existence is tracked in the shared
117133
backend while transports themselves remain local to each worker process.
134+
135+
Doctest (memory backend only):
136+
>>> from mcpgateway.cache.session_registry import SessionRegistry
137+
>>> class DummyTransport:
138+
... pass
139+
>>> reg = SessionRegistry(backend='memory')
140+
>>> import asyncio
141+
>>> asyncio.run(reg.add_session('sid', DummyTransport()))
142+
>>> t = asyncio.run(reg.get_session('sid'))
143+
>>> isinstance(t, DummyTransport)
144+
True
145+
>>> asyncio.run(reg.remove_session('sid'))
146+
>>> asyncio.run(reg.get_session('sid')) is None
147+
True
118148
"""
119149

120150
def __init__(

mcpgateway/utils/create_jwt_token.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,24 @@
1616
$ python3 jwt_cli.py
1717
1818
Library:
19-
```python
20-
from mcpgateway.utils.create_jwt_token import create_jwt_token, get_jwt_token
21-
22-
# inside async context
23-
jwt = await create_jwt_token({"username": "alice"})
24-
```
19+
from mcpgateway.utils.create_jwt_token import create_jwt_token, get_jwt_token
20+
21+
# inside async context
22+
jwt = await create_jwt_token({"username": "alice"})
23+
24+
Doctest examples
25+
----------------
26+
>>> from mcpgateway.utils import create_jwt_token as jwt_util
27+
>>> jwt_util.settings.jwt_secret_key = 'secret'
28+
>>> jwt_util.settings.jwt_algorithm = 'HS256'
29+
>>> token = jwt_util._create_jwt_token({'sub': 'alice'}, expires_in_minutes=1, secret='secret', algorithm='HS256')
30+
>>> import jwt
31+
>>> jwt.decode(token, 'secret', algorithms=['HS256'])['sub'] == 'alice'
32+
True
33+
>>> import asyncio
34+
>>> t = asyncio.run(jwt_util.create_jwt_token({'sub': 'bob'}, expires_in_minutes=1, secret='secret', algorithm='HS256'))
35+
>>> jwt.decode(t, 'secret', algorithms=['HS256'])['sub'] == 'bob'
36+
True
2537
"""
2638

2739
# Future
@@ -67,7 +79,8 @@ def _create_jwt_token(
6779
secret: str = DEFAULT_SECRET,
6880
algorithm: str = DEFAULT_ALGO,
6981
) -> str:
70-
"""Return a signed JWT string (synchronous, timezone-aware).
82+
"""
83+
Return a signed JWT string (synchronous, timezone-aware).
7184
7285
Args:
7386
data: Dictionary containing payload data to encode in the token.
@@ -78,6 +91,15 @@ def _create_jwt_token(
7891
7992
Returns:
8093
The JWT token string.
94+
95+
Doctest:
96+
>>> from mcpgateway.utils import create_jwt_token as jwt_util
97+
>>> jwt_util.settings.jwt_secret_key = 'secret'
98+
>>> jwt_util.settings.jwt_algorithm = 'HS256'
99+
>>> token = jwt_util._create_jwt_token({'sub': 'alice'}, expires_in_minutes=1, secret='secret', algorithm='HS256')
100+
>>> import jwt
101+
>>> jwt.decode(token, 'secret', algorithms=['HS256'])['sub'] == 'alice'
102+
True
81103
"""
82104
payload = data.copy()
83105
if expires_in_minutes > 0:
@@ -98,7 +120,8 @@ async def create_jwt_token(
98120
secret: str = DEFAULT_SECRET,
99121
algorithm: str = DEFAULT_ALGO,
100122
) -> str:
101-
"""Async facade for historic code. Internally synchronous-almost instant.
123+
"""
124+
Async facade for historic code. Internally synchronous-almost instant.
102125
103126
Args:
104127
data: Dictionary containing payload data to encode in the token.
@@ -109,6 +132,16 @@ async def create_jwt_token(
109132
110133
Returns:
111134
The JWT token string.
135+
136+
Doctest:
137+
>>> from mcpgateway.utils import create_jwt_token as jwt_util
138+
>>> jwt_util.settings.jwt_secret_key = 'secret'
139+
>>> jwt_util.settings.jwt_algorithm = 'HS256'
140+
>>> import asyncio
141+
>>> t = asyncio.run(jwt_util.create_jwt_token({'sub': 'bob'}, expires_in_minutes=1, secret='secret', algorithm='HS256'))
142+
>>> import jwt
143+
>>> jwt.decode(t, 'secret', algorithms=['HS256'])['sub'] == 'bob'
144+
True
112145
"""
113146
return _create_jwt_token(data, expires_in_minutes, secret, algorithm)
114147

mcpgateway/utils/db_isready.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,43 @@
4545
Shell ::
4646
4747
python3 db_isready.py
48-
python3 db_isready.py --database-url "postgresql://user:pw@db:5432/mcp" \
49-
--max-tries 20 --interval 1 --timeout 1
48+
python3 db_isready.py --database-url "sqlite:///./mcp.db" --max-tries 2 --interval 1 --timeout 1
5049
5150
Python ::
5251
53-
from db_isready import wait_for_db_ready
54-
55-
await wait_for_db_ready() # asynchronous
56-
wait_for_db_ready(sync=True) # synchronous / blocking
52+
from mcpgateway.utils.db_isready import wait_for_db_ready
53+
54+
# Synchronous/blocking
55+
wait_for_db_ready(sync=True)
56+
57+
# Asynchronous
58+
import asyncio
59+
asyncio.run(wait_for_db_ready())
60+
61+
Doctest examples
62+
----------------
63+
>>> from mcpgateway.utils.db_isready import wait_for_db_ready
64+
>>> import logging
65+
>>> class DummyLogger:
66+
... def __init__(self): self.infos = []
67+
... def info(self, msg): self.infos.append(msg)
68+
... def debug(self, msg): pass
69+
... def error(self, msg): pass
70+
... @property
71+
... def handlers(self): return [True]
72+
>>> import sys
73+
>>> sys.modules['sqlalchemy'] = type('sqlalchemy', (), {
74+
... 'create_engine': lambda *a, **k: type('E', (), {'connect': lambda self: type('C', (), {'execute': lambda self, q: 1, '__enter__': lambda self: self, '__exit__': lambda self, exc_type, exc_val, exc_tb: None})()})(),
75+
... 'text': lambda q: q,
76+
... 'engine': type('engine', (), {'Engine': object, 'URL': object, 'url': type('url', (), {'make_url': lambda u: type('U', (), {'get_backend_name': lambda self: "sqlite"})()}),}),
77+
... 'exc': type('exc', (), {'OperationalError': Exception})
78+
... })
79+
>>> wait_for_db_ready(database_url='sqlite:///./mcp.db', max_tries=1, interval=1, timeout=1, logger=DummyLogger(), sync=True)
80+
>>> try:
81+
... wait_for_db_ready(database_url='sqlite:///./mcp.db', max_tries=0, interval=1, timeout=1, logger=DummyLogger(), sync=True)
82+
... except RuntimeError as e:
83+
... print('error')
84+
error
5785
"""
5886

5987
# Future
@@ -173,7 +201,8 @@ def wait_for_db_ready(
173201
logger: Optional[logging.Logger] = None,
174202
sync: bool = False,
175203
) -> None:
176-
"""Block until the database replies to ``SELECT 1``.
204+
"""
205+
Block until the database replies to ``SELECT 1``.
177206
178207
The helper can be awaited **asynchronously** *or* called in *blocking*
179208
mode by passing ``sync=True``.
@@ -194,6 +223,30 @@ def wait_for_db_ready(
194223
Raises:
195224
RuntimeError: If *invalid* parameters are supplied or the database is
196225
still unavailable after the configured number of attempts.
226+
227+
Doctest:
228+
>>> from mcpgateway.utils.db_isready import wait_for_db_ready
229+
>>> import logging
230+
>>> class DummyLogger:
231+
... def __init__(self): self.infos = []
232+
... def info(self, msg): self.infos.append(msg)
233+
... def debug(self, msg): pass
234+
... def error(self, msg): pass
235+
... @property
236+
... def handlers(self): return [True]
237+
>>> import sys
238+
>>> sys.modules['sqlalchemy'] = type('sqlalchemy', (), {
239+
... 'create_engine': lambda *a, **k: type('E', (), {'connect': lambda self: type('C', (), {'execute': lambda self, q: 1, '__enter__': lambda self: self, '__exit__': lambda self, exc_type, exc_val, exc_tb: None})()})(),
240+
... 'text': lambda q: q,
241+
... 'engine': type('engine', (), {'Engine': object, 'URL': object, 'url': type('url', (), {'make_url': lambda u: type('U', (), {'get_backend_name': lambda self: "sqlite"})()}),}),
242+
... 'exc': type('exc', (), {'OperationalError': Exception})
243+
... })
244+
>>> wait_for_db_ready(database_url='sqlite:///./mcp.db', max_tries=1, interval=1, timeout=1, logger=DummyLogger(), sync=True)
245+
>>> try:
246+
... wait_for_db_ready(database_url='sqlite:///./mcp.db', max_tries=0, interval=1, timeout=1, logger=DummyLogger(), sync=True)
247+
... except RuntimeError as e:
248+
... print('error')
249+
error
197250
"""
198251

199252
log = logger or logging.getLogger("db_isready")

0 commit comments

Comments
 (0)