hypern 0.3.1__cp310-cp310-win_amd64.whl → 0.3.3__cp310-cp310-win_amd64.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.
- hypern/application.py +115 -16
- hypern/args_parser.py +34 -1
- hypern/caching/__init__.py +6 -0
- hypern/caching/backend.py +31 -0
- hypern/caching/redis_backend.py +200 -2
- hypern/caching/strategies.py +164 -71
- hypern/config.py +97 -0
- hypern/db/addons/__init__.py +5 -0
- hypern/db/addons/sqlalchemy/__init__.py +71 -0
- hypern/db/sql/__init__.py +13 -179
- hypern/db/sql/field.py +606 -0
- hypern/db/sql/model.py +116 -0
- hypern/db/sql/query.py +879 -0
- hypern/exceptions.py +10 -0
- hypern/gateway/__init__.py +6 -0
- hypern/gateway/aggregator.py +32 -0
- hypern/gateway/gateway.py +41 -0
- hypern/gateway/proxy.py +60 -0
- hypern/gateway/service.py +52 -0
- hypern/hypern.cp310-win_amd64.pyd +0 -0
- hypern/hypern.pyi +53 -18
- hypern/middleware/__init__.py +14 -2
- hypern/middleware/base.py +9 -14
- hypern/middleware/cache.py +177 -0
- hypern/middleware/compress.py +78 -0
- hypern/middleware/cors.py +6 -3
- hypern/middleware/limit.py +5 -4
- hypern/middleware/security.py +21 -16
- hypern/processpool.py +16 -32
- hypern/routing/__init__.py +2 -1
- hypern/routing/dispatcher.py +4 -0
- hypern/routing/queue.py +175 -0
- {hypern-0.3.1.dist-info → hypern-0.3.3.dist-info}/METADATA +3 -1
- hypern-0.3.3.dist-info/RECORD +84 -0
- hypern/caching/base/__init__.py +0 -8
- hypern/caching/base/backend.py +0 -3
- hypern/caching/base/key_maker.py +0 -8
- hypern/caching/cache_manager.py +0 -56
- hypern/caching/cache_tag.py +0 -10
- hypern/caching/custom_key_maker.py +0 -11
- hypern-0.3.1.dist-info/RECORD +0 -76
- /hypern/db/{sql/addons → addons/sqlalchemy/fields}/__init__.py +0 -0
- /hypern/db/{sql/addons → addons/sqlalchemy/fields}/color.py +0 -0
- /hypern/db/{sql/addons → addons/sqlalchemy/fields}/daterange.py +0 -0
- /hypern/db/{sql/addons → addons/sqlalchemy/fields}/datetime.py +0 -0
- /hypern/db/{sql/addons → addons/sqlalchemy/fields}/encrypted.py +0 -0
- /hypern/db/{sql/addons → addons/sqlalchemy/fields}/password.py +0 -0
- /hypern/db/{sql/addons → addons/sqlalchemy/fields}/ts_vector.py +0 -0
- /hypern/db/{sql/addons → addons/sqlalchemy/fields}/unicode.py +0 -0
- /hypern/db/{sql → addons/sqlalchemy}/repository.py +0 -0
- {hypern-0.3.1.dist-info → hypern-0.3.3.dist-info}/WHEEL +0 -0
- {hypern-0.3.1.dist-info → hypern-0.3.3.dist-info}/licenses/LICENSE +0 -0
hypern/caching/strategies.py
CHANGED
@@ -1,101 +1,188 @@
|
|
1
|
-
from typing import Any, Optional, Callable, TypeVar
|
2
|
-
from datetime import datetime
|
3
1
|
import asyncio
|
2
|
+
import time
|
3
|
+
from abc import ABC, abstractmethod
|
4
|
+
from typing import Callable, Generic, Optional, TypeVar
|
5
|
+
|
4
6
|
import orjson
|
5
7
|
|
6
|
-
from
|
8
|
+
from .backend import BaseBackend
|
7
9
|
|
8
10
|
T = TypeVar("T")
|
9
11
|
|
10
12
|
|
11
|
-
class
|
12
|
-
|
13
|
+
class CacheStrategy(ABC, Generic[T]):
|
14
|
+
"""Base class for cache strategies"""
|
15
|
+
|
16
|
+
@abstractmethod
|
17
|
+
async def get(self, key: str) -> Optional[T]:
|
18
|
+
pass
|
19
|
+
|
20
|
+
@abstractmethod
|
21
|
+
async def set(self, key: str, value: T, ttl: Optional[int] = None) -> None:
|
22
|
+
pass
|
23
|
+
|
24
|
+
@abstractmethod
|
25
|
+
async def delete(self, key: str) -> None:
|
26
|
+
pass
|
27
|
+
|
28
|
+
|
29
|
+
class CacheEntry(Generic[T]):
|
30
|
+
"""Represents a cached item with metadata"""
|
31
|
+
|
32
|
+
def __init__(self, value: T, created_at: float, ttl: int, revalidate_after: Optional[int] = None):
|
13
33
|
self.value = value
|
14
|
-
self.
|
15
|
-
self.
|
34
|
+
self.created_at = created_at
|
35
|
+
self.ttl = ttl
|
36
|
+
self.revalidate_after = revalidate_after
|
16
37
|
self.is_revalidating = False
|
17
38
|
|
18
|
-
def
|
19
|
-
|
39
|
+
def is_stale(self) -> bool:
|
40
|
+
"""Check if entry is stale and needs revalidation"""
|
41
|
+
now = time.time()
|
42
|
+
return self.revalidate_after is not None and now > (self.created_at + self.revalidate_after)
|
43
|
+
|
44
|
+
def is_expired(self) -> bool:
|
45
|
+
"""Check if entry has completely expired"""
|
46
|
+
now = time.time()
|
47
|
+
return now > (self.created_at + self.ttl)
|
48
|
+
|
49
|
+
def to_json(self) -> bytes:
|
50
|
+
"""Serialize entry to JSON"""
|
51
|
+
return orjson.dumps(
|
52
|
+
{
|
53
|
+
"value": self.value,
|
54
|
+
"created_at": self.created_at,
|
55
|
+
"ttl": self.ttl,
|
56
|
+
"revalidate_after": self.revalidate_after,
|
57
|
+
"is_revalidating": self.is_revalidating,
|
58
|
+
}
|
59
|
+
)
|
20
60
|
|
21
61
|
@classmethod
|
22
|
-
def from_json(cls, data:
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
62
|
+
def from_json(cls, data: bytes) -> "CacheEntry[T]":
|
63
|
+
"""Deserialize entry from JSON"""
|
64
|
+
parsed = orjson.loads(data)
|
65
|
+
return cls(value=parsed["value"], created_at=parsed["created_at"], ttl=parsed["ttl"], revalidate_after=parsed["revalidate_after"])
|
66
|
+
|
27
67
|
|
68
|
+
class StaleWhileRevalidateStrategy(CacheStrategy[T]):
|
69
|
+
"""
|
70
|
+
Implements stale-while-revalidate caching strategy.
|
71
|
+
Allows serving stale content while revalidating in the background.
|
72
|
+
"""
|
28
73
|
|
29
|
-
|
30
|
-
|
74
|
+
def __init__(self, backend: BaseBackend, revalidate_after: int, ttl: int, revalidate_fn: Callable[..., T]):
|
75
|
+
"""
|
76
|
+
Initialize the caching strategy.
|
77
|
+
|
78
|
+
Args:
|
79
|
+
backend (BaseBackend): The backend storage for caching.
|
80
|
+
revalidate_after (int): The time in seconds after which the cache should be revalidated.
|
81
|
+
ttl (int): The time-to-live for cache entries in seconds.
|
82
|
+
revalidate_fn (Callable[..., T]): The function to call for revalidating the cache.
|
83
|
+
|
84
|
+
Attributes:
|
85
|
+
backend (BaseBackend): The backend storage for caching.
|
86
|
+
revalidate_after (int): The time in seconds after which the cache should be revalidated.
|
87
|
+
ttl (int): The time-to-live for cache entries in seconds.
|
88
|
+
revalidate_fn (Callable[..., T]): The function to call for revalidating the cache.
|
89
|
+
_revalidation_locks (dict): A dictionary to manage revalidation locks.
|
90
|
+
"""
|
31
91
|
self.backend = backend
|
92
|
+
self.revalidate_after = revalidate_after
|
93
|
+
self.ttl = ttl
|
94
|
+
self.revalidate_fn = revalidate_fn
|
95
|
+
self._revalidation_locks: dict = {}
|
32
96
|
|
33
|
-
async def get(self, key: str
|
34
|
-
|
97
|
+
async def get(self, key: str) -> Optional[T]:
|
98
|
+
entry = await self.backend.get(key)
|
99
|
+
if not entry:
|
100
|
+
return None
|
35
101
|
|
102
|
+
if isinstance(entry, bytes):
|
103
|
+
entry = CacheEntry.from_json(entry)
|
36
104
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
105
|
+
# If entry is stale but not expired, trigger background revalidation
|
106
|
+
if entry.is_stale() and not entry.is_expired():
|
107
|
+
if not entry.is_revalidating:
|
108
|
+
entry.is_revalidating = True
|
109
|
+
await self.backend.set(key, entry.to_json())
|
110
|
+
asyncio.create_task(self._revalidate(key))
|
111
|
+
return entry.value
|
42
112
|
|
43
|
-
|
44
|
-
|
113
|
+
# If entry is expired, return None
|
114
|
+
if entry.is_expired():
|
115
|
+
return None
|
45
116
|
|
46
|
-
|
47
|
-
cached_data = await self.backend.get(key)
|
48
|
-
if cached_data:
|
49
|
-
entry = CacheEntry.from_json(cached_data)
|
117
|
+
return entry.value
|
50
118
|
|
51
|
-
|
52
|
-
|
53
|
-
|
119
|
+
async def set(self, key: str, value: T, ttl: Optional[int] = None) -> None:
|
120
|
+
entry = CacheEntry(value=value, created_at=time.time(), ttl=ttl or self.ttl, revalidate_after=self.revalidate_after)
|
121
|
+
await self.backend.set(key, entry.to_json(), ttl=ttl)
|
54
122
|
|
55
|
-
|
56
|
-
|
57
|
-
entry.is_revalidating = True
|
58
|
-
await self.backend.set(key, entry.to_json(), self.cache_ttl)
|
59
|
-
asyncio.create_task(self._revalidate(key, loader))
|
60
|
-
return entry.value
|
61
|
-
|
62
|
-
# Cache miss or expired - load fresh data
|
63
|
-
value = await loader()
|
64
|
-
entry = CacheEntry(value=value, expires_at=now + self.cache_ttl, stale_at=now + (self.cache_ttl - self.stale_ttl))
|
65
|
-
await self.backend.set(key, entry.to_json(), self.cache_ttl)
|
66
|
-
return value
|
123
|
+
async def delete(self, key: str) -> None:
|
124
|
+
await self.backend.delete(key)
|
67
125
|
|
68
|
-
async def _revalidate(self, key: str
|
126
|
+
async def _revalidate(self, key: str) -> None:
|
127
|
+
"""Background revalidation of cached data"""
|
69
128
|
try:
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
except Exception as e:
|
75
|
-
logger.error(f"Revalidation failed for key {key}: {e}")
|
129
|
+
# Prevent multiple simultaneous revalidations
|
130
|
+
if key in self._revalidation_locks:
|
131
|
+
return
|
132
|
+
self._revalidation_locks[key] = True
|
76
133
|
|
134
|
+
# Get fresh data
|
135
|
+
fresh_value = await self.revalidate_fn(key)
|
77
136
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
137
|
+
# Update cache with fresh data
|
138
|
+
await self.set(key, fresh_value)
|
139
|
+
finally:
|
140
|
+
self._revalidation_locks.pop(key, None)
|
82
141
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
142
|
+
|
143
|
+
class CacheAsideStrategy(CacheStrategy[T]):
|
144
|
+
"""
|
145
|
+
Implements cache-aside (lazy loading) strategy.
|
146
|
+
Data is loaded into cache only when requested.
|
147
|
+
"""
|
148
|
+
|
149
|
+
def __init__(self, backend: BaseBackend, load_fn: Callable[[str], T], ttl: int, write_through: bool = False):
|
150
|
+
self.backend = backend
|
151
|
+
self.load_fn = load_fn
|
152
|
+
self.ttl = ttl
|
153
|
+
self.write_through = write_through
|
154
|
+
|
155
|
+
async def get(self, key: str) -> Optional[T]:
|
156
|
+
# Try to get from cache first
|
157
|
+
value = await self.backend.get(key)
|
158
|
+
if value:
|
159
|
+
if isinstance(value, bytes):
|
160
|
+
value = orjson.loads(value)
|
161
|
+
return value
|
162
|
+
|
163
|
+
# On cache miss, load from source
|
164
|
+
value = await self.load_fn(key)
|
165
|
+
if value is not None:
|
166
|
+
await self.set(key, value)
|
95
167
|
return value
|
96
168
|
|
169
|
+
async def set(self, key: str, value: T, ttl: Optional[int] = None) -> None:
|
170
|
+
await self.backend.set(key, value, ttl or self.ttl)
|
171
|
+
|
172
|
+
# If write-through is enabled, update the source
|
173
|
+
if self.write_through:
|
174
|
+
await self._write_to_source(key, value)
|
175
|
+
|
176
|
+
async def delete(self, key: str) -> None:
|
177
|
+
await self.backend.delete(key)
|
97
178
|
|
98
|
-
def
|
179
|
+
async def _write_to_source(self, key: str, value: T) -> None:
|
180
|
+
"""Write to the source in write-through mode"""
|
181
|
+
if hasattr(self.load_fn, "write"):
|
182
|
+
await self.load_fn.write(key, value)
|
183
|
+
|
184
|
+
|
185
|
+
def cache_with_strategy(strategy: CacheStrategy, key_prefix: str | None = None, ttl: int = 3600):
|
99
186
|
"""
|
100
187
|
Decorator for using cache strategies
|
101
188
|
"""
|
@@ -105,10 +192,16 @@ def cache_with_strategy(strategy: CacheStrategy, key_prefix: str = None):
|
|
105
192
|
# Generate cache key
|
106
193
|
cache_key = f"{key_prefix or func.__name__}:{hash(str(args) + str(kwargs))}"
|
107
194
|
|
108
|
-
|
109
|
-
|
195
|
+
result = await strategy.get(cache_key)
|
196
|
+
if result is not None:
|
197
|
+
return result
|
198
|
+
|
199
|
+
# Execute function and cache result
|
200
|
+
result = await func(*args, **kwargs)
|
201
|
+
if result is not None:
|
202
|
+
await strategy.set(cache_key, result, ttl)
|
110
203
|
|
111
|
-
return
|
204
|
+
return result
|
112
205
|
|
113
206
|
return wrapper
|
114
207
|
|
hypern/config.py
CHANGED
@@ -1,9 +1,15 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import os
|
4
|
+
|
5
|
+
# -*- coding: utf-8 -*-
|
6
|
+
import threading
|
4
7
|
import typing
|
5
8
|
import warnings
|
9
|
+
from contextvars import ContextVar
|
10
|
+
from datetime import datetime
|
6
11
|
from pathlib import Path
|
12
|
+
from typing import Dict, Optional
|
7
13
|
|
8
14
|
"""
|
9
15
|
|
@@ -147,3 +153,94 @@ class Config:
|
|
147
153
|
return cast(value)
|
148
154
|
except (TypeError, ValueError):
|
149
155
|
raise ValueError(f"Config '{key}' has value '{value}'. Not a valid {cast.__name__}.")
|
156
|
+
|
157
|
+
|
158
|
+
class ContextStore:
|
159
|
+
def __init__(self, cleanup_interval: int = 300, max_age: int = 3600):
|
160
|
+
"""
|
161
|
+
Initialize ContextStore with automatic session cleanup.
|
162
|
+
|
163
|
+
:param cleanup_interval: Interval between cleanup checks (in seconds)
|
164
|
+
:param max_age: Maximum age of a session before it's considered expired (in seconds)
|
165
|
+
"""
|
166
|
+
self._session_times: Dict[str, datetime] = {}
|
167
|
+
self.session_var = ContextVar("session_id", default=None)
|
168
|
+
|
169
|
+
self._max_age = max_age
|
170
|
+
self._cleanup_interval = cleanup_interval
|
171
|
+
self._cleanup_thread: Optional[threading.Thread] = None
|
172
|
+
self._stop_event = threading.Event()
|
173
|
+
|
174
|
+
# Start the cleanup thread
|
175
|
+
self._start_cleanup_thread()
|
176
|
+
|
177
|
+
def _start_cleanup_thread(self):
|
178
|
+
"""Start a background thread for periodic session cleanup."""
|
179
|
+
|
180
|
+
def cleanup_worker():
|
181
|
+
while not self._stop_event.is_set():
|
182
|
+
self._perform_cleanup()
|
183
|
+
self._stop_event.wait(self._cleanup_interval)
|
184
|
+
|
185
|
+
self._cleanup_thread = threading.Thread(
|
186
|
+
target=cleanup_worker,
|
187
|
+
daemon=True, # Allows the thread to be automatically terminated when the main program exits
|
188
|
+
)
|
189
|
+
self._cleanup_thread.start()
|
190
|
+
|
191
|
+
def _perform_cleanup(self):
|
192
|
+
"""Perform cleanup of expired sessions."""
|
193
|
+
current_time = datetime.now()
|
194
|
+
expired_sessions = [
|
195
|
+
session_id for session_id, timestamp in list(self._session_times.items()) if (current_time - timestamp).total_seconds() > self._max_age
|
196
|
+
]
|
197
|
+
|
198
|
+
for session_id in expired_sessions:
|
199
|
+
self.remove_session(session_id)
|
200
|
+
|
201
|
+
def remove_session(self, session_id: str):
|
202
|
+
"""Remove a specific session."""
|
203
|
+
self._session_times.pop(session_id, None)
|
204
|
+
|
205
|
+
def set_context(self, session_id: str):
|
206
|
+
"""
|
207
|
+
Context manager for setting and resetting session context.
|
208
|
+
|
209
|
+
:param session_id: Unique identifier for the session
|
210
|
+
:return: Context manager for session
|
211
|
+
"""
|
212
|
+
self.session_var.set(session_id)
|
213
|
+
self._session_times[session_id] = datetime.now()
|
214
|
+
|
215
|
+
def get_context(self) -> str:
|
216
|
+
"""
|
217
|
+
Get the current session context.
|
218
|
+
|
219
|
+
:return: Current session ID
|
220
|
+
:raises RuntimeError: If no session context is available
|
221
|
+
"""
|
222
|
+
return self.session_var.get()
|
223
|
+
|
224
|
+
def reset_context(self):
|
225
|
+
"""Reset the session context."""
|
226
|
+
token = self.get_context()
|
227
|
+
if token is not None:
|
228
|
+
self.session_var.reset(token)
|
229
|
+
|
230
|
+
def stop_cleanup(self):
|
231
|
+
"""
|
232
|
+
Stop the cleanup thread.
|
233
|
+
Useful for graceful shutdown of the application.
|
234
|
+
"""
|
235
|
+
self._stop_event.set()
|
236
|
+
if self._cleanup_thread:
|
237
|
+
self._cleanup_thread.join()
|
238
|
+
|
239
|
+
def __del__(self):
|
240
|
+
"""
|
241
|
+
Ensure cleanup thread is stopped when the object is deleted.
|
242
|
+
"""
|
243
|
+
self.stop_cleanup()
|
244
|
+
|
245
|
+
|
246
|
+
context_store = ContextStore()
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
import asyncio
|
3
|
+
import traceback
|
4
|
+
from contextlib import asynccontextmanager
|
5
|
+
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_scoped_session
|
7
|
+
from sqlalchemy.orm import Session, sessionmaker
|
8
|
+
from sqlalchemy.sql.expression import Delete, Insert, Update
|
9
|
+
|
10
|
+
from .repository import Model, PostgresRepository
|
11
|
+
|
12
|
+
|
13
|
+
class SqlalchemyConfig:
|
14
|
+
def __init__(self, default_engine: AsyncEngine | None = None, reader_engine: AsyncEngine | None = None, writer_engine: AsyncEngine | None = None):
|
15
|
+
"""
|
16
|
+
Initialize the SQL configuration.
|
17
|
+
You can provide a default engine, a reader engine, and a writer engine.
|
18
|
+
If only one engine is provided (default_engine), it will be used for both reading and writing.
|
19
|
+
If both reader and writer engines are provided, they will be used for reading and writing respectively.
|
20
|
+
Note: The reader and writer engines must be different.
|
21
|
+
"""
|
22
|
+
|
23
|
+
assert default_engine or reader_engine or writer_engine, "At least one engine must be provided."
|
24
|
+
assert not (reader_engine and writer_engine and id(reader_engine) == id(writer_engine)), "Reader and writer engines must be different."
|
25
|
+
|
26
|
+
engines = {
|
27
|
+
"writer": writer_engine or default_engine,
|
28
|
+
"reader": reader_engine or default_engine,
|
29
|
+
}
|
30
|
+
|
31
|
+
class RoutingSession(Session):
|
32
|
+
def get_bind(this, mapper=None, clause=None, **kwargs):
|
33
|
+
if this._flushing or isinstance(clause, (Update, Delete, Insert)):
|
34
|
+
return engines["writer"].sync_engine
|
35
|
+
return engines["reader"].sync_engine
|
36
|
+
|
37
|
+
async_session_factory = sessionmaker(
|
38
|
+
class_=AsyncSession,
|
39
|
+
sync_session_class=RoutingSession,
|
40
|
+
expire_on_commit=False,
|
41
|
+
)
|
42
|
+
|
43
|
+
session_scope: AsyncSession | async_scoped_session = async_scoped_session(
|
44
|
+
session_factory=async_session_factory,
|
45
|
+
scopefunc=asyncio.current_task,
|
46
|
+
)
|
47
|
+
|
48
|
+
@asynccontextmanager
|
49
|
+
async def get_session():
|
50
|
+
"""
|
51
|
+
Get the database session.
|
52
|
+
This can be used for dependency injection.
|
53
|
+
|
54
|
+
:return: The database session.
|
55
|
+
"""
|
56
|
+
try:
|
57
|
+
yield session_scope
|
58
|
+
except Exception:
|
59
|
+
traceback.print_exc()
|
60
|
+
await session_scope.rollback()
|
61
|
+
finally:
|
62
|
+
await session_scope.remove()
|
63
|
+
await session_scope.close()
|
64
|
+
|
65
|
+
self.get_session = get_session
|
66
|
+
|
67
|
+
def init_app(self, app):
|
68
|
+
app.inject("get_session", self.get_session)
|
69
|
+
|
70
|
+
|
71
|
+
__all__ = ["Model", "PostgresRepository", "SqlalchemyConfig"]
|
hypern/db/sql/__init__.py
CHANGED
@@ -1,179 +1,13 @@
|
|
1
|
-
#
|
2
|
-
import
|
3
|
-
import
|
4
|
-
import
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
from hypern.hypern import Request, Response
|
16
|
-
|
17
|
-
from .repository import Model, PostgresRepository
|
18
|
-
|
19
|
-
|
20
|
-
class ContextStore:
|
21
|
-
def __init__(self, cleanup_interval: int = 300, max_age: int = 3600):
|
22
|
-
"""
|
23
|
-
Initialize ContextStore with automatic session cleanup.
|
24
|
-
|
25
|
-
:param cleanup_interval: Interval between cleanup checks (in seconds)
|
26
|
-
:param max_age: Maximum age of a session before it's considered expired (in seconds)
|
27
|
-
"""
|
28
|
-
self._session_times: Dict[str, datetime] = {}
|
29
|
-
self.session_var = ContextVar("session_id", default=None)
|
30
|
-
|
31
|
-
self._max_age = max_age
|
32
|
-
self._cleanup_interval = cleanup_interval
|
33
|
-
self._cleanup_thread: Optional[threading.Thread] = None
|
34
|
-
self._stop_event = threading.Event()
|
35
|
-
|
36
|
-
# Start the cleanup thread
|
37
|
-
self._start_cleanup_thread()
|
38
|
-
|
39
|
-
def _start_cleanup_thread(self):
|
40
|
-
"""Start a background thread for periodic session cleanup."""
|
41
|
-
|
42
|
-
def cleanup_worker():
|
43
|
-
while not self._stop_event.is_set():
|
44
|
-
self._perform_cleanup()
|
45
|
-
self._stop_event.wait(self._cleanup_interval)
|
46
|
-
|
47
|
-
self._cleanup_thread = threading.Thread(
|
48
|
-
target=cleanup_worker,
|
49
|
-
daemon=True, # Allows the thread to be automatically terminated when the main program exits
|
50
|
-
)
|
51
|
-
self._cleanup_thread.start()
|
52
|
-
|
53
|
-
def _perform_cleanup(self):
|
54
|
-
"""Perform cleanup of expired sessions."""
|
55
|
-
current_time = datetime.now()
|
56
|
-
expired_sessions = [
|
57
|
-
session_id for session_id, timestamp in list(self._session_times.items()) if (current_time - timestamp).total_seconds() > self._max_age
|
58
|
-
]
|
59
|
-
|
60
|
-
for session_id in expired_sessions:
|
61
|
-
self.remove_session(session_id)
|
62
|
-
|
63
|
-
def remove_session(self, session_id: str):
|
64
|
-
"""Remove a specific session."""
|
65
|
-
self._session_times.pop(session_id, None)
|
66
|
-
|
67
|
-
def set_context(self, session_id: str):
|
68
|
-
"""
|
69
|
-
Context manager for setting and resetting session context.
|
70
|
-
|
71
|
-
:param session_id: Unique identifier for the session
|
72
|
-
:return: Context manager for session
|
73
|
-
"""
|
74
|
-
self.session_var.set(session_id)
|
75
|
-
self._session_times[session_id] = datetime.now()
|
76
|
-
|
77
|
-
def get_context(self) -> str:
|
78
|
-
"""
|
79
|
-
Get the current session context.
|
80
|
-
|
81
|
-
:return: Current session ID
|
82
|
-
:raises RuntimeError: If no session context is available
|
83
|
-
"""
|
84
|
-
return self.session_var.get()
|
85
|
-
|
86
|
-
def reset_context(self):
|
87
|
-
"""Reset the session context."""
|
88
|
-
token = self.get_context()
|
89
|
-
if token is not None:
|
90
|
-
self.session_var.reset(token)
|
91
|
-
|
92
|
-
def stop_cleanup(self):
|
93
|
-
"""
|
94
|
-
Stop the cleanup thread.
|
95
|
-
Useful for graceful shutdown of the application.
|
96
|
-
"""
|
97
|
-
self._stop_event.set()
|
98
|
-
if self._cleanup_thread:
|
99
|
-
self._cleanup_thread.join()
|
100
|
-
|
101
|
-
def __del__(self):
|
102
|
-
"""
|
103
|
-
Ensure cleanup thread is stopped when the object is deleted.
|
104
|
-
"""
|
105
|
-
self.stop_cleanup()
|
106
|
-
|
107
|
-
|
108
|
-
class SqlConfig:
|
109
|
-
def __init__(self, default_engine: AsyncEngine | None = None, reader_engine: AsyncEngine | None = None, writer_engine: AsyncEngine | None = None):
|
110
|
-
"""
|
111
|
-
Initialize the SQL configuration.
|
112
|
-
You can provide a default engine, a reader engine, and a writer engine.
|
113
|
-
If only one engine is provided (default_engine), it will be used for both reading and writing.
|
114
|
-
If both reader and writer engines are provided, they will be used for reading and writing respectively.
|
115
|
-
Note: The reader and writer engines must be different.
|
116
|
-
"""
|
117
|
-
|
118
|
-
assert default_engine or reader_engine or writer_engine, "At least one engine must be provided."
|
119
|
-
assert not (reader_engine and writer_engine and id(reader_engine) == id(writer_engine)), "Reader and writer engines must be different."
|
120
|
-
|
121
|
-
engines = {
|
122
|
-
"writer": writer_engine or default_engine,
|
123
|
-
"reader": reader_engine or default_engine,
|
124
|
-
}
|
125
|
-
self.session_store = ContextStore()
|
126
|
-
|
127
|
-
class RoutingSession(Session):
|
128
|
-
def get_bind(this, mapper=None, clause=None, **kwargs):
|
129
|
-
if this._flushing or isinstance(clause, (Update, Delete, Insert)):
|
130
|
-
return engines["writer"].sync_engine
|
131
|
-
return engines["reader"].sync_engine
|
132
|
-
|
133
|
-
async_session_factory = sessionmaker(
|
134
|
-
class_=AsyncSession,
|
135
|
-
sync_session_class=RoutingSession,
|
136
|
-
expire_on_commit=False,
|
137
|
-
)
|
138
|
-
|
139
|
-
session_scope: Union[AsyncSession, async_scoped_session] = async_scoped_session(
|
140
|
-
session_factory=async_session_factory,
|
141
|
-
scopefunc=asyncio.current_task,
|
142
|
-
)
|
143
|
-
|
144
|
-
@asynccontextmanager
|
145
|
-
async def get_session():
|
146
|
-
"""
|
147
|
-
Get the database session.
|
148
|
-
This can be used for dependency injection.
|
149
|
-
|
150
|
-
:return: The database session.
|
151
|
-
"""
|
152
|
-
try:
|
153
|
-
yield session_scope
|
154
|
-
except Exception:
|
155
|
-
traceback.print_exc()
|
156
|
-
await session_scope.rollback()
|
157
|
-
finally:
|
158
|
-
await session_scope.remove()
|
159
|
-
await session_scope.close()
|
160
|
-
|
161
|
-
self.get_session = get_session
|
162
|
-
self._context_token: Optional[Token] = None
|
163
|
-
|
164
|
-
def before_request(self, request: Request):
|
165
|
-
token = str(uuid4())
|
166
|
-
self.session_store.set_context(token)
|
167
|
-
return request
|
168
|
-
|
169
|
-
def after_request(self, response: Response):
|
170
|
-
self.session_store.reset_context()
|
171
|
-
return response
|
172
|
-
|
173
|
-
def init_app(self, app):
|
174
|
-
app.inject("get_session", self.get_session)
|
175
|
-
app.before_request()(self.before_request)
|
176
|
-
app.after_request()(self.after_request)
|
177
|
-
|
178
|
-
|
179
|
-
__all__ = ["Model", "PostgresRepository", "SqlConfig"]
|
1
|
+
# from .context import SqlConfig, DatabaseType
|
2
|
+
from .field import CharField, IntegerField
|
3
|
+
from .model import Model
|
4
|
+
from .query import F, Q, QuerySet
|
5
|
+
|
6
|
+
__all__ = [
|
7
|
+
"CharField",
|
8
|
+
"IntegerField",
|
9
|
+
"Model",
|
10
|
+
"Q",
|
11
|
+
"F",
|
12
|
+
"QuerySet",
|
13
|
+
]
|