Skip to content

Commit bcb7466

Browse files
Allow2CEOruvnet
andcommitted
Add storage and cache implementations
FileTokenStorage (JSON file, chmod 0o600), MemoryTokenStorage (dict-based), FileCache (per-key files with TTL), MemoryCache (dict with TTL) -- Python equivalents of the PHP FileTokenStorage, SessionTokenStorage, FileCache, and ArrayCache. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent bdd427d commit bcb7466

6 files changed

Lines changed: 276 additions & 0 deletions

File tree

allow2_service/cache/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Cache implementations."""
2+
3+
from allow2_service.cache.file_cache import FileCache
4+
from allow2_service.cache.memory_cache import MemoryCache
5+
6+
__all__ = [
7+
"FileCache",
8+
"MemoryCache",
9+
]

allow2_service/cache/file_cache.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""File-based permission cache.
2+
3+
Each cache entry is stored as a separate JSON file in the cache directory.
4+
Suitable for single-server deployments.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import os
11+
import re
12+
import time
13+
from typing import Optional
14+
15+
from allow2_service.cache_interface import CacheInterface
16+
17+
18+
class FileCache(CacheInterface):
19+
"""File-based permission cache.
20+
21+
Each cache entry is stored as a separate JSON file in the cache
22+
directory. Expired entries are removed lazily on read.
23+
24+
Args:
25+
cache_dir: Directory for cache files. Created automatically if missing.
26+
"""
27+
28+
def __init__(self, cache_dir: str) -> None:
29+
self._cache_dir = cache_dir
30+
if not os.path.isdir(self._cache_dir):
31+
os.makedirs(self._cache_dir, mode=0o700, exist_ok=True)
32+
33+
def get(self, key: str) -> Optional[str]:
34+
"""Retrieve a cached value by key."""
35+
path = self._key_to_path(key)
36+
37+
if not os.path.exists(path):
38+
return None
39+
40+
try:
41+
with open(path, "r", encoding="utf-8") as f:
42+
entry = json.load(f)
43+
except (json.JSONDecodeError, OSError):
44+
self._safe_unlink(path)
45+
return None
46+
47+
if "expires_at" not in entry or int(time.time()) >= entry["expires_at"]:
48+
self._safe_unlink(path)
49+
return None
50+
51+
return entry.get("value")
52+
53+
def set(self, key: str, value: str, ttl: int = 60) -> None:
54+
"""Store a value in cache."""
55+
path = self._key_to_path(key)
56+
57+
entry = json.dumps({
58+
"value": value,
59+
"expires_at": int(time.time()) + ttl,
60+
})
61+
62+
with open(path, "w", encoding="utf-8") as f:
63+
f.write(entry)
64+
65+
def delete(self, key: str) -> None:
66+
"""Delete a cached value."""
67+
path = self._key_to_path(key)
68+
self._safe_unlink(path)
69+
70+
def _key_to_path(self, key: str) -> str:
71+
"""Convert a cache key to a safe filesystem path."""
72+
safe_key = re.sub(r"[^a-zA-Z0-9_\-]", "_", key)
73+
return os.path.join(self._cache_dir, safe_key + ".json")
74+
75+
@staticmethod
76+
def _safe_unlink(path: str) -> None:
77+
"""Remove a file if it exists, suppressing errors."""
78+
try:
79+
os.unlink(path)
80+
except OSError:
81+
pass
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""In-memory cache that lives for the duration of the process.
2+
3+
No persistence between restarts. Useful for testing or when you
4+
only need to deduplicate checks within a single request lifecycle.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import time
10+
from typing import Dict, Optional
11+
12+
from allow2_service.cache_interface import CacheInterface
13+
14+
15+
class MemoryCache(CacheInterface):
16+
"""In-memory dict-based cache with TTL support.
17+
18+
Each entry stores a value and an ``expires_at`` timestamp.
19+
Expired entries are removed lazily on read.
20+
"""
21+
22+
def __init__(self) -> None:
23+
self._store: Dict[str, Dict[str, object]] = {}
24+
25+
def get(self, key: str) -> Optional[str]:
26+
"""Retrieve a cached value by key."""
27+
if key not in self._store:
28+
return None
29+
30+
entry = self._store[key]
31+
if int(time.time()) >= int(entry["expires_at"]): # type: ignore[arg-type]
32+
del self._store[key]
33+
return None
34+
35+
return str(entry["value"])
36+
37+
def set(self, key: str, value: str, ttl: int = 60) -> None:
38+
"""Store a value in cache."""
39+
self._store[key] = {
40+
"value": value,
41+
"expires_at": int(time.time()) + ttl,
42+
}
43+
44+
def delete(self, key: str) -> None:
45+
"""Delete a cached value."""
46+
self._store.pop(key, None)

allow2_service/storage/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Token storage implementations."""
2+
3+
from allow2_service.storage.file_storage import FileTokenStorage
4+
from allow2_service.storage.memory_storage import MemoryTokenStorage
5+
6+
__all__ = [
7+
"FileTokenStorage",
8+
"MemoryTokenStorage",
9+
]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""File-based JSON token storage.
2+
3+
Stores all tokens in a single JSON file. Suitable for development
4+
and single-server deployments. Not recommended for high-concurrency
5+
production use.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import os
12+
from typing import Any, Dict, Optional
13+
14+
from allow2_service.models.oauth_tokens import OAuthTokens
15+
from allow2_service.token_storage import TokenStorageInterface
16+
17+
18+
class FileTokenStorage(TokenStorageInterface):
19+
"""File-based JSON token storage.
20+
21+
Stores all tokens in a single JSON file with ``chmod 0600``
22+
for security.
23+
24+
Args:
25+
file_path: Path to the JSON storage file.
26+
"""
27+
28+
def __init__(self, file_path: str) -> None:
29+
self._file_path = file_path
30+
self._data: Optional[Dict[str, Any]] = None
31+
32+
def store(self, user_id: str, tokens: OAuthTokens) -> None:
33+
"""Store tokens for a user."""
34+
data = self._load_all()
35+
data[user_id] = tokens.to_dict()
36+
self._save_all(data)
37+
38+
def retrieve(self, user_id: str) -> Optional[OAuthTokens]:
39+
"""Retrieve tokens for a user, or None if none stored."""
40+
data = self._load_all()
41+
42+
if user_id not in data:
43+
return None
44+
45+
return OAuthTokens.from_dict(data[user_id])
46+
47+
def delete(self, user_id: str) -> None:
48+
"""Delete tokens for a user."""
49+
data = self._load_all()
50+
data.pop(user_id, None)
51+
self._save_all(data)
52+
53+
def exists(self, user_id: str) -> bool:
54+
"""Check whether tokens exist for a user."""
55+
data = self._load_all()
56+
return user_id in data
57+
58+
def _load_all(self) -> Dict[str, Any]:
59+
"""Load all tokens from disk."""
60+
if self._data is not None:
61+
return self._data
62+
63+
if not os.path.exists(self._file_path):
64+
self._data = {}
65+
return self._data
66+
67+
try:
68+
with open(self._file_path, "r", encoding="utf-8") as f:
69+
self._data = json.load(f)
70+
except (json.JSONDecodeError, OSError):
71+
self._data = {}
72+
73+
return self._data
74+
75+
def _save_all(self, data: Dict[str, Any]) -> None:
76+
"""Persist all tokens to disk."""
77+
self._data = data
78+
79+
dir_path = os.path.dirname(self._file_path)
80+
if dir_path and not os.path.isdir(dir_path):
81+
os.makedirs(dir_path, mode=0o700, exist_ok=True)
82+
83+
with open(self._file_path, "w", encoding="utf-8") as f:
84+
json.dump(data, f, indent=2)
85+
86+
try:
87+
os.chmod(self._file_path, 0o600)
88+
except OSError:
89+
pass # chmod may not be available on all platforms
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""In-memory token storage.
2+
3+
Suitable for testing or single-process development. Tokens are lost
4+
when the process exits.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Dict, Optional
10+
11+
from allow2_service.models.oauth_tokens import OAuthTokens
12+
from allow2_service.token_storage import TokenStorageInterface
13+
14+
15+
class MemoryTokenStorage(TokenStorageInterface):
16+
"""In-memory dict-based token storage.
17+
18+
No persistence between process restarts. Useful for testing or
19+
simple single-process applications.
20+
"""
21+
22+
def __init__(self) -> None:
23+
self._store: Dict[str, Dict[str, object]] = {}
24+
25+
def store(self, user_id: str, tokens: OAuthTokens) -> None:
26+
"""Store tokens for a user."""
27+
self._store[user_id] = tokens.to_dict()
28+
29+
def retrieve(self, user_id: str) -> Optional[OAuthTokens]:
30+
"""Retrieve tokens for a user, or None if none stored."""
31+
if user_id not in self._store:
32+
return None
33+
34+
return OAuthTokens.from_dict(self._store[user_id]) # type: ignore[arg-type]
35+
36+
def delete(self, user_id: str) -> None:
37+
"""Delete tokens for a user."""
38+
self._store.pop(user_id, None)
39+
40+
def exists(self, user_id: str) -> bool:
41+
"""Check whether tokens exist for a user."""
42+
return user_id in self._store

0 commit comments

Comments
 (0)