hypern 0.3.0__cp312-cp312-win32.whl → 0.3.1__cp312-cp312-win32.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.
@@ -0,0 +1,115 @@
1
+ from typing import Any, Optional, Callable, TypeVar
2
+ from datetime import datetime
3
+ import asyncio
4
+ import orjson
5
+
6
+ from hypern.logging import logger
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class CacheEntry:
12
+ def __init__(self, value: Any, expires_at: int, stale_at: Optional[int] = None):
13
+ self.value = value
14
+ self.expires_at = expires_at
15
+ self.stale_at = stale_at or expires_at
16
+ self.is_revalidating = False
17
+
18
+ def to_json(self) -> str:
19
+ return orjson.dumps({"value": self.value, "expires_at": self.expires_at, "stale_at": self.stale_at, "is_revalidating": self.is_revalidating})
20
+
21
+ @classmethod
22
+ def from_json(cls, data: str) -> "CacheEntry":
23
+ data_dict = orjson.loads(data)
24
+ entry = cls(value=data_dict["value"], expires_at=data_dict["expires_at"], stale_at=data_dict["stale_at"])
25
+ entry.is_revalidating = data_dict["is_revalidating"]
26
+ return entry
27
+
28
+
29
+ class CacheStrategy:
30
+ def __init__(self, backend: Any):
31
+ self.backend = backend
32
+
33
+ async def get(self, key: str, loader: Callable[[], T]) -> T:
34
+ raise NotImplementedError
35
+
36
+
37
+ class StaleWhileRevalidateStrategy(CacheStrategy):
38
+ def __init__(self, backend: Any, stale_ttl: int, cache_ttl: int):
39
+ super().__init__(backend)
40
+ self.stale_ttl = stale_ttl
41
+ self.cache_ttl = cache_ttl
42
+
43
+ async def get(self, key: str, loader: Callable[[], T]) -> T:
44
+ now = int(datetime.now().timestamp())
45
+
46
+ # Try to get from cache
47
+ cached_data = await self.backend.get(key)
48
+ if cached_data:
49
+ entry = CacheEntry.from_json(cached_data)
50
+
51
+ if now < entry.stale_at:
52
+ # Cache is fresh
53
+ return entry.value
54
+
55
+ if now < entry.expires_at and not entry.is_revalidating:
56
+ # Cache is stale but usable - trigger background revalidation
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
67
+
68
+ async def _revalidate(self, key: str, loader: Callable[[], T]):
69
+ try:
70
+ value = await loader()
71
+ now = int(datetime.now().timestamp())
72
+ entry = CacheEntry(value=value, expires_at=now + self.cache_ttl, stale_at=now + (self.cache_ttl - self.stale_ttl))
73
+ await self.backend.set(key, entry.to_json(), self.cache_ttl)
74
+ except Exception as e:
75
+ logger.error(f"Revalidation failed for key {key}: {e}")
76
+
77
+
78
+ class CacheAsideStrategy(CacheStrategy):
79
+ def __init__(self, backend: Any, ttl: int):
80
+ super().__init__(backend)
81
+ self.ttl = ttl
82
+
83
+ async def get(self, key: str, loader: Callable[[], T]) -> T:
84
+ # Try to get from cache
85
+ cached_data = await self.backend.get(key)
86
+ if cached_data:
87
+ entry = CacheEntry.from_json(cached_data)
88
+ if entry.expires_at > int(datetime.now().timestamp()):
89
+ return entry.value
90
+
91
+ # Cache miss or expired - load from source
92
+ value = await loader()
93
+ entry = CacheEntry(value=value, expires_at=int(datetime.now().timestamp()) + self.ttl)
94
+ await self.backend.set(key, entry.to_json(), self.ttl)
95
+ return value
96
+
97
+
98
+ def cache_with_strategy(strategy: CacheStrategy, key_prefix: str = None):
99
+ """
100
+ Decorator for using cache strategies
101
+ """
102
+
103
+ def decorator(func):
104
+ async def wrapper(*args, **kwargs):
105
+ # Generate cache key
106
+ cache_key = f"{key_prefix or func.__name__}:{hash(str(args) + str(kwargs))}"
107
+
108
+ async def loader():
109
+ return await func(*args, **kwargs)
110
+
111
+ return await strategy.get(cache_key, loader)
112
+
113
+ return wrapper
114
+
115
+ return decorator
Binary file
hypern/hypern.pyi CHANGED
@@ -16,6 +16,9 @@ class RedisBackend(BaseBackend):
16
16
  get: Callable[[str], Any]
17
17
  set: Callable[[Any, str, int], None]
18
18
  delete_startswith: Callable[[str], None]
19
+ set_nx: Callable[[Any, str, int], None]
20
+ get_ttl: Callable[[str], int]
21
+ current_timestamp: Callable[[], int]
19
22
 
20
23
  @dataclass
21
24
  class BaseSchemaGenerator:
@@ -257,11 +260,16 @@ class WebsocketRouter:
257
260
  class Header:
258
261
  headers: Dict[str, str]
259
262
 
263
+ def get(self, key: str) -> str | None: ...
264
+ def set(self, key: str, value: str) -> None: ...
265
+ def append(self, key: str, value: str) -> None: ...
266
+ def update(self, headers: Dict[str, str]) -> None: ...
267
+
260
268
  @dataclass
261
269
  class Response:
262
270
  status_code: int
263
271
  response_type: str
264
- headers: Any
272
+ headers: Header
265
273
  description: str
266
274
  file_path: str
267
275
 
@@ -286,7 +294,7 @@ class BodyData:
286
294
  @dataclass
287
295
  class Request:
288
296
  query_params: QueryParams
289
- headers: Dict[str, str]
297
+ headers: Header
290
298
  path_params: Dict[str, str]
291
299
  body: BodyData
292
300
  method: str
@@ -0,0 +1,179 @@
1
+ import hashlib
2
+ import hmac
3
+ import secrets
4
+ import time
5
+ from base64 import b64decode, b64encode
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import jwt
11
+
12
+ from hypern.exceptions import Forbidden, Unauthorized
13
+ from hypern.hypern import Middleware, Request, Response
14
+
15
+
16
+ @dataclass
17
+ class CORSConfig:
18
+ allowed_origins: List[str]
19
+ allowed_methods: List[str]
20
+ max_age: int
21
+
22
+
23
+ @dataclass
24
+ class SecurityConfig:
25
+ rate_limiting: bool = False
26
+ jwt_auth: bool = False
27
+ cors_configuration: Optional[CORSConfig] = None
28
+ csrf_protection: bool = False
29
+ security_headers: Optional[Dict[str, str]] = None
30
+ jwt_secret: str = ""
31
+ jwt_algorithm: str = "HS256"
32
+ jwt_expires_in: int = 3600 # 1 hour in seconds
33
+
34
+ def __post_init__(self):
35
+ if self.cors_configuration:
36
+ self.cors_configuration = CORSConfig(**self.cors_configuration)
37
+
38
+ if self.security_headers is None:
39
+ self.security_headers = {
40
+ "X-Frame-Options": "DENY",
41
+ "X-Content-Type-Options": "nosniff",
42
+ "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
43
+ }
44
+
45
+
46
+ class SecurityMiddleware(Middleware):
47
+ def __init__(self, config: SecurityConfig):
48
+ super().__init__()
49
+ self.config = config
50
+ self._secret_key = secrets.token_bytes(32)
51
+ self._token_lifetime = 3600
52
+ self._rate_limit_storage = {}
53
+
54
+ def _rate_limit_check(self, request: Request) -> Optional[Response]:
55
+ """Check if the request exceeds rate limits"""
56
+ if not self.config.rate_limiting:
57
+ return None
58
+
59
+ client_ip = request.client.host
60
+ current_time = time.time()
61
+ window_start = int(current_time - 60) # 1-minute window
62
+
63
+ # Clean up old entries
64
+ self._rate_limit_storage = {ip: hits for ip, hits in self._rate_limit_storage.items() if hits["timestamp"] > window_start}
65
+
66
+ if client_ip not in self._rate_limit_storage:
67
+ self._rate_limit_storage[client_ip] = {"count": 1, "timestamp": current_time}
68
+ else:
69
+ self._rate_limit_storage[client_ip]["count"] += 1
70
+
71
+ if self._rate_limit_storage[client_ip]["count"] > 60: # 60 requests per minute
72
+ return Response(status_code=429, description=b"Too Many Requests", headers={"Retry-After": "60"})
73
+ return None
74
+
75
+ def _generate_jwt_token(self, user_data: Dict[str, Any]) -> str:
76
+ """Generate a JWT token"""
77
+ if not self.config.jwt_secret:
78
+ raise ValueError("JWT secret key is not configured")
79
+
80
+ payload = {"user": user_data, "exp": datetime.utcnow() + timedelta(seconds=self.config.jwt_expires_in), "iat": datetime.utcnow()}
81
+ return jwt.encode(payload, self.config.jwt_secret, algorithm=self.config.jwt_algorithm)
82
+
83
+ def _verify_jwt_token(self, token: str) -> Dict[str, Any]:
84
+ """Verify JWT token and return payload"""
85
+ try:
86
+ payload = jwt.decode(token, self.config.jwt_secret, algorithms=[self.config.jwt_algorithm])
87
+ return payload
88
+ except jwt.ExpiredSignatureError:
89
+ raise Unauthorized("Token has expired")
90
+ except jwt.InvalidTokenError:
91
+ raise Unauthorized("Invalid token")
92
+
93
+ def _generate_csrf_token(self, session_id: str) -> str:
94
+ """Generate a new CSRF token"""
95
+ timestamp = str(int(time.time()))
96
+ token_data = f"{session_id}:{timestamp}"
97
+ signature = hmac.new(self._secret_key, token_data.encode(), hashlib.sha256).digest()
98
+ return b64encode(f"{token_data}:{b64encode(signature).decode()}".encode()).decode()
99
+
100
+ def _validate_csrf_token(self, token: str) -> bool:
101
+ """Validate CSRF token"""
102
+ try:
103
+ decoded_token = b64decode(token.encode()).decode()
104
+ session_id, timestamp, signature = decoded_token.rsplit(":", 2)
105
+
106
+ # Verify timestamp
107
+ token_time = int(timestamp)
108
+ current_time = int(time.time())
109
+ if current_time - token_time > self._token_lifetime:
110
+ return False
111
+
112
+ # Verify signature
113
+ expected_data = f"{session_id}:{timestamp}"
114
+ expected_signature = hmac.new(self._secret_key, expected_data.encode(), hashlib.sha256).digest()
115
+
116
+ actual_signature = b64decode(signature)
117
+ return hmac.compare_digest(expected_signature, actual_signature)
118
+
119
+ except (ValueError, AttributeError, TypeError):
120
+ return False
121
+
122
+ def _apply_cors_headers(self, response: Response) -> None:
123
+ """Apply CORS headers to response"""
124
+ if not self.config.cors_configuration:
125
+ return
126
+
127
+ cors = self.config.cors_configuration
128
+ response.headers.update(
129
+ {
130
+ "Access-Control-Allow-Origin": ", ".join(cors.allowed_origins),
131
+ "Access-Control-Allow-Methods": ", ".join(cors.allowed_methods),
132
+ "Access-Control-Max-Age": str(cors.max_age),
133
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-CSRF-Token",
134
+ "Access-Control-Allow-Credentials": "true",
135
+ }
136
+ )
137
+
138
+ def _apply_security_headers(self, response: Response) -> None:
139
+ """Apply security headers to response"""
140
+ if self.config.security_headers:
141
+ response.headers.update(self.config.security_headers)
142
+
143
+ async def before_request(self, request: Request) -> Request | Response:
144
+ """Process request before handling"""
145
+ # Rate limiting check
146
+ if rate_limit_response := self._rate_limit_check(request):
147
+ return rate_limit_response
148
+
149
+ # JWT authentication check
150
+ if self.config.jwt_auth:
151
+ auth_header = request.headers.get("Authorization")
152
+ if not auth_header or not auth_header.startswith("Bearer "):
153
+ raise Unauthorized("Missing or invalid authorization header")
154
+ token = auth_header.split(" ")[1]
155
+ try:
156
+ request.user = self._verify_jwt_token(token)
157
+ except Unauthorized as e:
158
+ return Response(status_code=401, description=str(e))
159
+
160
+ # CSRF protection check
161
+ if self.config.csrf_protection and request.method in ["POST", "PUT", "DELETE", "PATCH"]:
162
+ csrf_token = request.headers.get("X-CSRF-Token")
163
+ if not csrf_token or not self._validate_csrf_token(csrf_token):
164
+ raise Forbidden("CSRF token missing or invalid")
165
+
166
+ return request
167
+
168
+ async def after_request(self, response: Response) -> Response:
169
+ """Process response after handling"""
170
+ self._apply_security_headers(response)
171
+ self._apply_cors_headers(response)
172
+ return response
173
+
174
+ def generate_csrf_token(self, request: Request) -> str:
175
+ """Generate and set CSRF token for the request"""
176
+ if not hasattr(request, "session_id"):
177
+ request.session_id = secrets.token_urlsafe(32)
178
+ token = self._generate_csrf_token(request.session_id)
179
+ return token
hypern/reload.py CHANGED
@@ -2,6 +2,8 @@ import sys
2
2
  import time
3
3
  import subprocess
4
4
  from watchdog.events import FileSystemEventHandler
5
+ import signal
6
+ import os
5
7
 
6
8
  from .logging import logger
7
9
 
@@ -10,51 +12,35 @@ class EventHandler(FileSystemEventHandler):
10
12
  def __init__(self, file_path: str, directory_path: str) -> None:
11
13
  self.file_path = file_path
12
14
  self.directory_path = directory_path
13
- self.process = None # Keep track of the subprocess
14
- self.last_reload = time.time() # Keep track of the last reload. EventHandler is initialized with the process.
15
-
16
- def stop_server(self):
17
- if self.process:
18
- try:
19
- # Check if the process is still alive
20
- if self.process.poll() is None: # None means the process is still running
21
- self.process.terminate() # Gracefully terminate the process
22
- self.process.wait(timeout=5) # Wait for the process to exit
23
- else:
24
- logger.error("Process is not running.")
25
- except subprocess.TimeoutExpired:
26
- logger.error("Process did not terminate in time. Forcing termination.")
27
- self.process.kill() # Forcefully kill the process if it doesn't stop
28
- except ProcessLookupError:
29
- logger.error("Process does not exist.")
30
- except Exception as e:
31
- logger.error(f"An error occurred while stopping the server: {e}")
32
- else:
33
- logger.debug("No process to stop.")
15
+ self.process = None
16
+ self.last_reload = time.time()
34
17
 
35
18
  def reload(self):
36
- self.stop_server()
37
- logger.debug("Reloading the server")
38
- prev_process = self.process
39
- if prev_process:
40
- prev_process.kill()
41
-
42
- self.process = subprocess.Popen(
43
- [sys.executable, *sys.argv],
44
- )
45
-
46
- self.last_reload = time.time()
19
+ # Kill all existing processes with the same command
20
+ current_cmd = [sys.executable, *sys.argv]
21
+
22
+ try:
23
+ # Find and kill existing processes
24
+ for proc in subprocess.Popen(["ps", "aux"], stdout=subprocess.PIPE).communicate()[0].decode().splitlines():
25
+ if all(str(arg) in proc for arg in current_cmd):
26
+ pid = int(proc.split()[1])
27
+ try:
28
+ os.kill(pid, signal.SIGKILL) # NOSONAR
29
+ logger.debug(f"Killed process with PID {pid}")
30
+ except ProcessLookupError:
31
+ pass
32
+
33
+ # Start new process
34
+ self.process = subprocess.Popen(current_cmd)
35
+ self.last_reload = time.time()
36
+ logger.debug("Server reloaded successfully")
37
+
38
+ except Exception as e:
39
+ logger.error(f"Reload failed: {e}")
47
40
 
48
41
  def on_modified(self, event) -> None:
49
- """
50
- This function is a callback that will start a new server on every even change
51
-
52
- :param event FSEvent: a data structure with info about the events
53
- """
54
-
55
- # Avoid reloading multiple times when watchdog detects multiple events
56
42
  if time.time() - self.last_reload < 0.5:
57
43
  return
58
44
 
59
- time.sleep(0.2) # Wait for the file to be fully written
45
+ time.sleep(0.2) # Ensure file is written
60
46
  self.reload()
hypern/ws/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from hypern.hypern import WebSocketSession
2
+ from .route import WebsocketRoute
3
+
4
+ __all__ = ["WebsocketRoute", "WebSocketSession"]
hypern/ws/channel.py ADDED
@@ -0,0 +1,80 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Awaitable, Callable, Dict, Set
3
+
4
+ from hypern.hypern import WebSocketSession
5
+
6
+
7
+ @dataclass
8
+ class Channel:
9
+ name: str
10
+ subscribers: Set[WebSocketSession] = field(default_factory=set)
11
+ handlers: Dict[str, Callable[[WebSocketSession, Any], Awaitable[None]]] = field(default_factory=dict)
12
+
13
+ def publish(self, event: str, data: Any, publisher: WebSocketSession = None):
14
+ """Publish an event to all subscribers except the publisher"""
15
+ for subscriber in self.subscribers:
16
+ if subscriber != publisher:
17
+ subscriber.send({"channel": self.name, "event": event, "data": data})
18
+
19
+ def handle_event(self, event: str, session: WebSocketSession, data: Any):
20
+ """Handle an event on this channel"""
21
+ if event in self.handlers:
22
+ self.handlers[event](session, data)
23
+
24
+ def add_subscriber(self, subscriber: WebSocketSession):
25
+ """Add a subscriber to the channel"""
26
+ self.subscribers.add(subscriber)
27
+
28
+ def remove_subscriber(self, subscriber: WebSocketSession):
29
+ """Remove a subscriber from the channel"""
30
+ self.subscribers.discard(subscriber)
31
+
32
+ def on(self, event: str):
33
+ """Decorator for registering event handlers"""
34
+
35
+ def decorator(handler: Callable[[WebSocketSession, Any], Awaitable[None]]):
36
+ self.handlers[event] = handler
37
+ return handler
38
+
39
+ return decorator
40
+
41
+
42
+ class ChannelManager:
43
+ def __init__(self):
44
+ self.channels: Dict[str, Channel] = {}
45
+ self.client_channels: Dict[WebSocketSession, Set[str]] = {}
46
+
47
+ def create_channel(self, channel_name: str) -> Channel:
48
+ """Create a new channel if it doesn't exist"""
49
+ if channel_name not in self.channels:
50
+ self.channels[channel_name] = Channel(channel_name)
51
+ return self.channels[channel_name]
52
+
53
+ def get_channel(self, channel_name: str) -> Channel:
54
+ """Get a channel by name"""
55
+ return self.channels.get(channel_name)
56
+
57
+ def subscribe(self, client: WebSocketSession, channel_name: str):
58
+ """Subscribe a client to a channel"""
59
+ channel = self.create_channel(channel_name)
60
+ channel.add_subscriber(client)
61
+
62
+ if client not in self.client_channels:
63
+ self.client_channels[client] = set()
64
+ self.client_channels[client].add(channel_name)
65
+
66
+ def unsubscribe(self, client: WebSocketSession, channel_name: str):
67
+ """Unsubscribe a client from a channel"""
68
+ channel = self.get_channel(channel_name)
69
+ if channel:
70
+ channel.remove_subscriber(client)
71
+ if client in self.client_channels:
72
+ self.client_channels[client].discard(channel_name)
73
+
74
+ def unsubscribe_all(self, client: WebSocketSession):
75
+ """Unsubscribe a client from all channels"""
76
+ if client in self.client_channels:
77
+ channels = self.client_channels[client].copy()
78
+ for channel_name in channels:
79
+ self.unsubscribe(client, channel_name)
80
+ del self.client_channels[client]
hypern/ws/heartbeat.py ADDED
@@ -0,0 +1,74 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from time import time
4
+ from typing import Dict
5
+
6
+ from hypern.hypern import WebSocketSession
7
+
8
+
9
+ @dataclass
10
+ class HeartbeatConfig:
11
+ ping_interval: float = 30.0 # Send ping every 30 seconds
12
+ ping_timeout: float = 10.0 # Wait 10 seconds for pong response
13
+ max_missed_pings: int = 2 # Disconnect after 2 missed pings
14
+
15
+
16
+ class HeartbeatManager:
17
+ def __init__(self, config: HeartbeatConfig = None):
18
+ self.config = config or HeartbeatConfig()
19
+ self.active_sessions: Dict[WebSocketSession, float] = {}
20
+ self.ping_tasks: Dict[WebSocketSession, asyncio.Task] = {}
21
+ self.missed_pings: Dict[WebSocketSession, int] = {}
22
+
23
+ async def start_heartbeat(self, session: WebSocketSession):
24
+ """Start heartbeat monitoring for a session"""
25
+ self.active_sessions[session] = time()
26
+ self.missed_pings[session] = 0
27
+ self.ping_tasks[session] = asyncio.create_task(self._heartbeat_loop(session))
28
+
29
+ async def stop_heartbeat(self, session: WebSocketSession):
30
+ """Stop heartbeat monitoring for a session"""
31
+ if session in self.ping_tasks:
32
+ self.ping_tasks[session].cancel()
33
+ del self.ping_tasks[session]
34
+ self.active_sessions.pop(session, None)
35
+ self.missed_pings.pop(session, None)
36
+
37
+ async def handle_pong(self, session: WebSocketSession):
38
+ """Handle pong response from client"""
39
+ if session in self.active_sessions:
40
+ self.active_sessions[session] = time()
41
+ self.missed_pings[session] = 0
42
+
43
+ async def _heartbeat_loop(self, session: WebSocketSession):
44
+ """Main heartbeat loop for a session"""
45
+ try:
46
+ while True:
47
+ await asyncio.sleep(self.config.ping_interval)
48
+
49
+ if session not in self.active_sessions:
50
+ break
51
+
52
+ # Send ping frame
53
+ try:
54
+ await session.ping()
55
+ last_pong = self.active_sessions[session]
56
+
57
+ # Wait for pong timeout
58
+ await asyncio.sleep(self.config.ping_timeout)
59
+
60
+ # Check if we received a pong
61
+ if self.active_sessions[session] == last_pong:
62
+ self.missed_pings[session] += 1
63
+
64
+ # Check if we exceeded max missed pings
65
+ if self.missed_pings[session] >= self.config.max_missed_pings:
66
+ await session.close(1001, "Connection timeout")
67
+ break
68
+
69
+ except Exception as e:
70
+ await session.close(1001, f"Heartbeat failed: {str(e)}")
71
+ break
72
+
73
+ finally:
74
+ await self.stop_heartbeat(session)
hypern/ws/room.py ADDED
@@ -0,0 +1,76 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Dict, Set
3
+
4
+ from hypern.hypern import WebSocketSession
5
+
6
+
7
+ @dataclass
8
+ class Room:
9
+ name: str
10
+ clients: Set[WebSocketSession] = field(default_factory=set)
11
+
12
+ def broadcast(self, message: str, exclude: WebSocketSession = None):
13
+ """Broadcast message to all clients in the room except excluded one"""
14
+ for client in self.clients:
15
+ if client != exclude:
16
+ client.send(message)
17
+
18
+ def add_client(self, client: WebSocketSession):
19
+ """Add a client to the room"""
20
+ self.clients.add(client)
21
+
22
+ def remove_client(self, client: WebSocketSession):
23
+ """Remove a client from the room"""
24
+ self.clients.discard(client)
25
+
26
+ @property
27
+ def client_count(self) -> int:
28
+ return len(self.clients)
29
+
30
+
31
+ class RoomManager:
32
+ def __init__(self):
33
+ self.rooms: Dict[str, Room] = {}
34
+ self.client_rooms: Dict[WebSocketSession, Set[str]] = {}
35
+
36
+ def create_room(self, room_name: str) -> Room:
37
+ """Create a new room if it doesn't exist"""
38
+ if room_name not in self.rooms:
39
+ self.rooms[room_name] = Room(room_name)
40
+ return self.rooms[room_name]
41
+
42
+ def get_room(self, room_name: str) -> Room:
43
+ """Get a room by name"""
44
+ return self.rooms.get(room_name)
45
+
46
+ def join_room(self, client: WebSocketSession, room_name: str):
47
+ """Add a client to a room"""
48
+ room = self.create_room(room_name)
49
+ room.add_client(client)
50
+
51
+ if client not in self.client_rooms:
52
+ self.client_rooms[client] = set()
53
+ self.client_rooms[client].add(room_name)
54
+
55
+ room.broadcast(f"Client joined room: {room_name}", exclude=client)
56
+
57
+ def leave_room(self, client: WebSocketSession, room_name: str):
58
+ """Remove a client from a room"""
59
+ room = self.get_room(room_name)
60
+ if room:
61
+ room.remove_client(client)
62
+ if client in self.client_rooms:
63
+ self.client_rooms[client].discard(room_name)
64
+
65
+ room.broadcast(f"Client left room: {room_name}", exclude=client)
66
+
67
+ if room.client_count == 0:
68
+ del self.rooms[room_name]
69
+
70
+ def leave_all_rooms(self, client: WebSocketSession):
71
+ """Remove a client from all rooms"""
72
+ if client in self.client_rooms:
73
+ rooms = self.client_rooms[client].copy()
74
+ for room_name in rooms:
75
+ self.leave_room(client, room_name)
76
+ del self.client_rooms[client]
hypern/ws/route.py ADDED
@@ -0,0 +1,26 @@
1
+ from typing import Callable, Optional
2
+
3
+ from hypern.hypern import WebsocketRoute as WebsocketRouteInternal, WebSocketSession
4
+
5
+
6
+ class WebsocketRoute:
7
+ def __init__(self) -> None:
8
+ self.routes = []
9
+ self._disconnect_handler: Optional[Callable] = None
10
+
11
+ def on(self, path):
12
+ def wrapper(func):
13
+ self.routes.append(WebsocketRouteInternal(path, func))
14
+ return func
15
+
16
+ return wrapper
17
+
18
+ def on_disconnect(self, func):
19
+ """Register a disconnect handler"""
20
+ self._disconnect_handler = func
21
+ return func
22
+
23
+ def handle_disconnect(self, session: WebSocketSession):
24
+ """Internal method to handle disconnection"""
25
+ if self._disconnect_handler:
26
+ return self._disconnect_handler(session)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypern
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: Implementation :: PyPy
@@ -1,6 +1,6 @@
1
- hypern-0.3.0.dist-info/METADATA,sha256=aG7HYARcYTZed6140LAyzlcSeZ189_4OOZIT0bqiOxE,3754
2
- hypern-0.3.0.dist-info/WHEEL,sha256=llwl3fX8kOi_BiOUBLq5Qrnznqct4O7tJSEJsqg9n1c,92
3
- hypern-0.3.0.dist-info/licenses/LICENSE,sha256=qbYKAIJLS6jYg5hYncKE7OtWmqOtpVTvKNkwOa0Iwwg,1328
1
+ hypern-0.3.1.dist-info/METADATA,sha256=dAKnEF83pJsKjgE50V8YZOD-_dTDlcrM3yc4Gtn5_IE,3754
2
+ hypern-0.3.1.dist-info/WHEEL,sha256=SK_cql1gpDHx6aBV-LOSvGbTt4TUC8AJJOzjOP2tdpI,92
3
+ hypern-0.3.1.dist-info/licenses/LICENSE,sha256=qbYKAIJLS6jYg5hYncKE7OtWmqOtpVTvKNkwOa0Iwwg,1328
4
4
  hypern/application.py,sha256=D8tye5rpOJzHGQHUelaDpjUL3B9xO40B7xAL9wC3Uno,14328
5
5
  hypern/args_parser.py,sha256=Crxzr8_uhiIk_AWJvuwJTEfRqEBqU_GfTbg6chg_YiY,1790
6
6
  hypern/auth/authorization.py,sha256=-NprZsI0np889ZN1fp-MiVFrPoMNzUtatBJaCMtkllM,32
@@ -13,6 +13,7 @@ hypern/caching/cache_manager.py,sha256=TF6UosJ54950JgsrPVZUS3MH2R8zafAu5PTryNJ0s
13
13
  hypern/caching/cache_tag.py,sha256=bZcjivMNETAzAHAIobuLN0S2wHgPgLLL8Gg4uso_qbk,267
14
14
  hypern/caching/custom_key_maker.py,sha256=88RIIJjpQYFnv857wOlCKgWWBbK_S23zNHsIrJz_4PY,394
15
15
  hypern/caching/redis_backend.py,sha256=IgQToCnHYGpKEErq2CNZkR5woo01z456Ef3C-XRPRV8,70
16
+ hypern/caching/strategies.py,sha256=S7qJiJOTJrk6KUFDrmjGkHP3XV8mYTRM3Sd4pA4UQKA,4199
16
17
  hypern/caching/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
18
  hypern/cli/commands.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
19
  hypern/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -38,7 +39,7 @@ hypern/db/sql/__init__.py,sha256=1UoWQi2CIcUAbQj3FadR-8V0o_b286nI2wYvOsvtbFc,647
38
39
  hypern/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
40
  hypern/enum.py,sha256=KcVziJj7vWvyie0r2rtxhrLzdtkZAsf0DY58oJ4tQl4,360
40
41
  hypern/exceptions.py,sha256=nHTkF0YdNBMKfSiNtjRMHMNKoY3RMUm68YYluuW15us,2428
41
- hypern/hypern.pyi,sha256=b5Zjr9ipm43rI8eSUV4WYmKpIJWUsv6O2TrpvvbeTlE,7700
42
+ hypern/hypern.pyi,sha256=DhTRcW9c0qjVS976fx__insErvhk4k7W0NKcV6iZGxw,8038
42
43
  hypern/i18n/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
44
  hypern/logging/logger.py,sha256=WACam_IJiCMXX0hGVKMGSxUQpY4DgAXy7M1dD3q-Z9s,3256
44
45
  hypern/logging/__init__.py,sha256=6eVriyncsJ4J73fGYhoejv9MX7aGTkRezTpPxO4DX1I,52
@@ -46,13 +47,14 @@ hypern/middleware/base.py,sha256=mJoz-i-7lqw1eDxZ8Bb0t8sz60lx5TE-OjZT4UR75e4,541
46
47
  hypern/middleware/cors.py,sha256=x90DnCOJSfp4ojm1krttn_EdtlqeDazyUzVg66NES4A,1681
47
48
  hypern/middleware/i18n.py,sha256=jHzVzjTx1nnjbraZtIVOprrnSaeKMxZB8RuSqRp2I4s,16
48
49
  hypern/middleware/limit.py,sha256=8hzUxu_mxra2QiDjAghgZtvwN6Dx07irPUiL12dbVhY,8152
50
+ hypern/middleware/security.py,sha256=hIbmP1fkZ_KgxK1Wx7s9xdNH7M174mo6ySASiuzMz_A,7273
49
51
  hypern/middleware/__init__.py,sha256=lXwR3fdmpVK4Z7QWaLsgf3Sazy5NPPFXIOxIEv1xDC8,273
50
52
  hypern/openapi/schemas.py,sha256=YHfMlPUeP5DzDX5ao3YH8p_25Vvyaf616dh6XDCUZRc,1677
51
53
  hypern/openapi/swagger.py,sha256=naqUY3rFAEYA1ZLIlmDsMYaol0yIm6TVebdkFa5cMTc,64
52
54
  hypern/openapi/__init__.py,sha256=4rEVD8pa0kdSpsy7ZkJ5JY0Z2XF0NGSKDMwYAd7YZpE,141
53
55
  hypern/processpool.py,sha256=YW7Dg33Hla9D33x3Mf8xsjaTprHaovkLPK-4XnQKiGU,4045
54
56
  hypern/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
- hypern/reload.py,sha256=d28xP7MWiO2vL6RAAlIqSh8q7_ugsRBN66Mlbz3UWEI,2293
57
+ hypern/reload.py,sha256=nfaZCoChrQetHNtIqN4Xzi-a0v-irxSCMhwCK3bCEq0,1569
56
58
  hypern/response/response.py,sha256=-dnboAraPic8asf503PxwmDuxhNllUO5h97_DGmbER4,4582
57
59
  hypern/response/__init__.py,sha256=_w3u3TDNuYx5ejnnN1unqnTY8NlBgUATQi6wepEB_FQ,226
58
60
  hypern/routing/dispatcher.py,sha256=i2wLAAW1ZXgpi5K2heGXhTODnP1WdQzaR5WlUjs1o9c,2368
@@ -63,7 +65,12 @@ hypern/routing/__init__.py,sha256=7rw7EAxougCXtmkgJjrmLP3N5RXctIpI_3JmG9FcKVU,10
63
65
  hypern/scheduler.py,sha256=-k3tW2AGCnHYSthKXk-FOs_SCtWp3yIxQzwzUJMJsbo,67
64
66
  hypern/security.py,sha256=3E86Yp_eOSVa1emUvBrDgoF0Sn6eNX0CfLnt87w5CPI,1773
65
67
  hypern/worker.py,sha256=WQrhY_awR6zjMwY4Q7izXi4E4fFrDqt7jIblUW8Bzcg,924
68
+ hypern/ws/channel.py,sha256=0ns2qmeoFJOpGLXS_hqldhywDQm_DxHwj6KloQx4Q3I,3183
69
+ hypern/ws/heartbeat.py,sha256=sWMXzQm6cbDHHA2NHc-gFjv7G_E56XtxswHQ93_BueM,2861
70
+ hypern/ws/room.py,sha256=0_L6Nun0n007F0rfNY8yX5x_A8EuXuI67JqpMkJ4RNI,2598
71
+ hypern/ws/route.py,sha256=fGQ2RC708MPOiiIHPUo8aZ-oK379TTAyQYm4htNA5jM,803
72
+ hypern/ws/__init__.py,sha256=dhRoRY683_rfPfSPM5qUczfTuyYDeuLOCFxY4hIdKt8,131
66
73
  hypern/ws.py,sha256=F6SA2Z1KVnqTEX8ssvOXqCtudUS4eo30JsiIsvfbHnE,394
67
74
  hypern/__init__.py,sha256=9Ww_aUQ0vJls0tOq7Yw1_TVOCRsa5bHJ-RtnSeComwk,119
68
- hypern/hypern.cp312-win32.pyd,sha256=Ufztl71auGJOE_0x63ZldXhd94YTxB_52KhlaaEODG4,5471744
69
- hypern-0.3.0.dist-info/RECORD,,
75
+ hypern/hypern.cp312-win32.pyd,sha256=GClLmHIIv0CKb5uHz_ochUZKY_1wketwqHGB9TUdRPg,5482496
76
+ hypern-0.3.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: maturin (1.7.7)
2
+ Generator: maturin (1.7.8)
3
3
  Root-Is-Purelib: false
4
4
  Tag: cp312-cp312-win32