hypern 0.3.0__cp312-cp312-win32.whl → 0.3.2__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,60 @@
1
+ import asyncio
2
+ from typing import Any, Dict, Optional
3
+
4
+ import aiohttp
5
+ import orjson
6
+ import traceback
7
+
8
+ from hypern.hypern import Request
9
+ from hypern.response import JSONResponse
10
+
11
+ from .service import ServiceConfig, ServiceRegistry, ServiceStatus
12
+
13
+
14
+ class Proxy:
15
+ def __init__(self, service_registry: ServiceRegistry):
16
+ self._registry = service_registry
17
+ self._session: Optional[aiohttp.ClientSession] = None
18
+ self._rate_limiters: Dict[str, asyncio.Semaphore] = {}
19
+
20
+ async def startup(self):
21
+ self._session = aiohttp.ClientSession()
22
+ for service in self._registry._services.values():
23
+ self._rate_limiters[service.name] = asyncio.Semaphore(100) # Default 100 concurrent requests
24
+
25
+ async def shutdown(self):
26
+ if self._session:
27
+ await self._session.close()
28
+
29
+ async def forward_request(self, service: ServiceConfig, request: Request) -> Any:
30
+ if not self._session:
31
+ await self.startup()
32
+
33
+ target_path = request.path.replace(service.prefix, "", 1)
34
+ target_url = f"{service.url}{target_path}"
35
+
36
+ headers = request.headers.get_headers()
37
+ # Remove hop-by-hop headers
38
+ for header in ["connection", "keep-alive", "transfer-encoding"]:
39
+ headers.pop(header, None)
40
+
41
+ async with self._rate_limiters[service.name]:
42
+ try:
43
+ async with self._session.request(
44
+ method=request.method,
45
+ url=target_url,
46
+ headers=headers,
47
+ params=request.query_params.to_dict(),
48
+ data=await request.json() if request.method in ["POST", "PUT", "PATCH"] else None,
49
+ timeout=aiohttp.ClientTimeout(total=service.timeout),
50
+ ) as response:
51
+ body = await response.read()
52
+ return JSONResponse(
53
+ content=orjson.loads(body) if response.content_type == "application/json" else body.decode(),
54
+ status_code=response.status,
55
+ headers=dict(response.headers),
56
+ )
57
+ except Exception as e:
58
+ traceback.print_exc()
59
+ self._registry.update_status(service.name, ServiceStatus.DEGRADED)
60
+ return JSONResponse(content={"error": "Service unavailable", "details": str(e)}, status_code=503)
@@ -0,0 +1,52 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ class ServiceStatus(Enum):
7
+ ONLINE = "online"
8
+ OFFLINE = "offline"
9
+ DEGRADED = "degraded"
10
+
11
+
12
+ @dataclass
13
+ class ServiceConfig:
14
+ name: str
15
+ url: str
16
+ prefix: str
17
+ timeout: float = 30.0
18
+ max_retries: int = 3
19
+ health_check_path: str = "/health"
20
+
21
+
22
+ class ServiceRegistry:
23
+ def __init__(self):
24
+ self._services: Dict[str, ServiceConfig] = {}
25
+ self._status: Dict[str, ServiceStatus] = {}
26
+ self._metadata: Dict[str, Dict[str, Any]] = {}
27
+
28
+ def register(self, service: ServiceConfig, metadata: Optional[Dict[str, Any]] = None):
29
+ self._services[service.name] = service
30
+ self._status[service.name] = ServiceStatus.ONLINE
31
+ self._metadata[service.name] = metadata or {}
32
+
33
+ def unregister(self, service_name: str):
34
+ self._services.pop(service_name, None)
35
+ self._status.pop(service_name, None)
36
+ self._metadata.pop(service_name, None)
37
+
38
+ def get_service(self, service_name: str) -> Optional[ServiceConfig]:
39
+ return self._services.get(service_name)
40
+
41
+ def get_service_by_prefix(self, path: str) -> Optional[ServiceConfig]:
42
+ for service in self._services.values():
43
+ if path.startswith(service.prefix):
44
+ return service
45
+ return None
46
+
47
+ def update_status(self, service_name: str, status: ServiceStatus):
48
+ if service_name in self._services:
49
+ self._status[service_name] = status
50
+
51
+ def get_status(self, service_name: str) -> ServiceStatus:
52
+ return self._status.get(service_name, ServiceStatus.OFFLINE)
Binary file
hypern/hypern.pyi CHANGED
@@ -3,20 +3,6 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass
4
4
  from typing import Any, Callable, Dict, List, Tuple
5
5
 
6
- @dataclass
7
- class BaseBackend:
8
- get: Callable[[str], Any]
9
- set: Callable[[Any, str, int], None]
10
- delete_startswith: Callable[[str], None]
11
-
12
- @dataclass
13
- class RedisBackend(BaseBackend):
14
- url: str
15
-
16
- get: Callable[[str], Any]
17
- set: Callable[[Any, str, int], None]
18
- delete_startswith: Callable[[str], None]
19
-
20
6
  @dataclass
21
7
  class BaseSchemaGenerator:
22
8
  remove_converter: Callable[[str], str]
@@ -199,6 +185,9 @@ class Server:
199
185
  def set_before_hooks(self, hooks: List[FunctionInfo]) -> None: ...
200
186
  def set_after_hooks(self, hooks: List[FunctionInfo]) -> None: ...
201
187
  def set_response_headers(self, headers: Dict[str, str]) -> None: ...
188
+ def set_startup_handler(self, on_startup: FunctionInfo) -> None: ...
189
+ def set_shutdown_handler(self, on_shutdown: FunctionInfo) -> None: ...
190
+ def set_auto_compression(self, enabled: bool) -> None: ...
202
191
 
203
192
  class Route:
204
193
  path: str
@@ -257,18 +246,27 @@ class WebsocketRouter:
257
246
  class Header:
258
247
  headers: Dict[str, str]
259
248
 
249
+ def get(self, key: str) -> str | None: ...
250
+ def set(self, key: str, value: str) -> None: ...
251
+ def append(self, key: str, value: str) -> None: ...
252
+ def update(self, headers: Dict[str, str]) -> None: ...
253
+ def get_headers(self) -> Dict[str, str]: ...
254
+
260
255
  @dataclass
261
256
  class Response:
262
257
  status_code: int
263
258
  response_type: str
264
- headers: Any
259
+ headers: Header
265
260
  description: str
266
- file_path: str
261
+ file_path: str | None
262
+ context_id: str
267
263
 
268
264
  @dataclass
269
265
  class QueryParams:
270
266
  queries: Dict[str, List[str]]
271
267
 
268
+ def to_dict(self) -> Dict[str, str]: ...
269
+
272
270
  @dataclass
273
271
  class UploadedFile:
274
272
  name: str
@@ -285,11 +283,23 @@ class BodyData:
285
283
 
286
284
  @dataclass
287
285
  class Request:
286
+ path: str
288
287
  query_params: QueryParams
289
- headers: Dict[str, str]
288
+ headers: Header
290
289
  path_params: Dict[str, str]
291
290
  body: BodyData
292
291
  method: str
293
292
  remote_addr: str
294
293
  timestamp: float
295
294
  context_id: str
295
+
296
+ def json(self) -> Dict[str, Any]: ...
297
+ def set_body(self, body: BodyData) -> None: ...
298
+
299
+ @dataclass
300
+ class MiddlewareConfig:
301
+ priority: int = 0
302
+ is_conditional: bool = True
303
+
304
+ @staticmethod
305
+ def default(self) -> MiddlewareConfig: ...
@@ -1,5 +1,17 @@
1
- from .base import Middleware
1
+ from .base import Middleware, MiddlewareConfig
2
2
  from .cors import CORSMiddleware
3
3
  from .limit import RateLimitMiddleware, StorageBackend, RedisBackend, InMemoryBackend
4
+ from .compress import CompressionMiddleware
5
+ from .cache import EdgeCacheMiddleware
4
6
 
5
- __all__ = ["Middleware", "CORSMiddleware", "RateLimitMiddleware", "StorageBackend", "RedisBackend", "InMemoryBackend"]
7
+ __all__ = [
8
+ "Middleware",
9
+ "CORSMiddleware",
10
+ "RateLimitMiddleware",
11
+ "StorageBackend",
12
+ "RedisBackend",
13
+ "InMemoryBackend",
14
+ "CompressionMiddleware",
15
+ "EdgeCacheMiddleware",
16
+ "MiddlewareConfig",
17
+ ]
hypern/middleware/base.py CHANGED
@@ -1,18 +1,13 @@
1
- from abc import ABC, abstractmethod
2
- from hypern.hypern import Response, Request
1
+ from typing import Optional
2
+ from hypern.hypern import MiddlewareConfig
3
3
 
4
4
 
5
- # The `Middleware` class is an abstract base class with abstract methods `before_request` and
6
- # `after_request` for handling requests and responses in a web application.
7
- class Middleware(ABC):
8
- def __init__(self) -> None:
9
- super().__init__()
10
- self.app = None
5
+ class Middleware:
6
+ def __init__(self, config: Optional[MiddlewareConfig] = None):
7
+ self.config = config or MiddlewareConfig.default()
11
8
 
12
- @abstractmethod
13
- def before_request(self, request: Request):
14
- pass
9
+ async def before_request(self, request):
10
+ return request
15
11
 
16
- @abstractmethod
17
- def after_request(self, response: Response):
18
- pass
12
+ async def after_request(self, response):
13
+ return response
@@ -0,0 +1,177 @@
1
+ import hashlib
2
+ from datetime import datetime, timezone
3
+ from typing import Dict, List, Optional
4
+
5
+ from hypern.hypern import Header, MiddlewareConfig, Request, Response
6
+
7
+ from .base import Middleware
8
+
9
+
10
+ class CacheConfig:
11
+ """
12
+ Configuration class for caching middleware.
13
+
14
+ Attributes:
15
+ max_age (int): The maximum age (in seconds) for the cache. Default is 3600 seconds (1 hour).
16
+ s_maxage (Optional[int]): The shared maximum age (in seconds) for the cache. Default is None.
17
+ stale_while_revalidate (Optional[int]): The time (in seconds) the cache can be used while revalidation is performed. Default is None.
18
+ stale_if_error (Optional[int]): The time (in seconds) the cache can be used if an error occurs during revalidation. Default is None.
19
+ vary_by (List[str]): List of headers to vary the cache by. Default is ['Accept', 'Accept-Encoding'].
20
+ cache_control (List[str]): List of cache control directives. Default is an empty list.
21
+ include_query_string (bool): Whether to include the query string in the cache key. Default is True.
22
+ exclude_paths (List[str]): List of paths to exclude from caching. Default is ['/admin', '/api/private'].
23
+ exclude_methods (List[str]): List of HTTP methods to exclude from caching. Default is ['POST', 'PUT', 'DELETE', 'PATCH'].
24
+ private_paths (List[str]): List of paths to be marked as private. Default is an empty list.
25
+ cache_by_headers (List[str]): List of headers to include in the cache key. Default is an empty list.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ max_age: int = 3600, # 1 hour default
31
+ s_maxage: Optional[int] = None,
32
+ stale_while_revalidate: Optional[int] = None,
33
+ stale_if_error: Optional[int] = None,
34
+ vary_by: List[str] = None,
35
+ cache_control: List[str] = None,
36
+ include_query_string: bool = True,
37
+ exclude_paths: List[str] = None,
38
+ exclude_methods: List[str] = None,
39
+ private_paths: List[str] = None,
40
+ cache_by_headers: List[str] = None,
41
+ ):
42
+ self.max_age = max_age
43
+ self.s_maxage = s_maxage
44
+ self.stale_while_revalidate = stale_while_revalidate
45
+ self.stale_if_error = stale_if_error
46
+ self.vary_by = vary_by or ["accept", "accept-encoding"]
47
+ self.cache_control = cache_control or []
48
+ self.include_query_string = include_query_string
49
+ self.exclude_paths = exclude_paths or ["/admin", "/api/private"]
50
+ self.exclude_methods = exclude_methods or ["POST", "PUT", "DELETE", "PATCH"]
51
+ self.private_paths = private_paths or []
52
+ self.cache_by_headers = cache_by_headers or []
53
+
54
+
55
+ class EdgeCacheMiddleware(Middleware):
56
+ """
57
+ Middleware implementing edge caching strategies with support for:
58
+ - Cache-Control directives
59
+ - ETag generation
60
+ - Conditional requests (If-None-Match, If-Modified-Since)
61
+ - Vary header management
62
+ - CDN-specific headers
63
+ """
64
+
65
+ def __init__(self, cache_config: CacheConfig | None = None, config: Optional[MiddlewareConfig] = None):
66
+ super().__init__(config)
67
+ self.cache_config = cache_config or CacheConfig()
68
+ self._etag_cache: Dict[str, str] = {}
69
+ self.request_context = {}
70
+
71
+ def _should_cache(self, request: Request, path: str) -> bool:
72
+ """Determine if the request should be cached"""
73
+ if request.method in self.cache_config.exclude_methods:
74
+ return False
75
+
76
+ if any(excluded in path for excluded in self.cache_config.exclude_paths):
77
+ return False
78
+
79
+ return True
80
+
81
+ def _generate_cache_key(self, request: Request) -> str:
82
+ """Generate a unique cache key based on request attributes"""
83
+ components = [request.method, request.path]
84
+
85
+ if self.cache_config.include_query_string:
86
+ components.append(str(request.query_params))
87
+
88
+ for header in self.cache_config.cache_by_headers:
89
+ value = request.headers.get(str(header).lower())
90
+ if value:
91
+ components.append(f"{header}:{value}")
92
+
93
+ return hashlib.sha256(":".join(components).encode()).hexdigest()
94
+
95
+ def _generate_etag(self, response: Response) -> str:
96
+ """Generate ETag for response content"""
97
+ content = response.description
98
+ if not isinstance(content, bytes):
99
+ content = str(content).encode()
100
+ return hashlib.sha256(content).hexdigest()
101
+
102
+ def _build_cache_control(self, path: str) -> str:
103
+ """Build Cache-Control header value"""
104
+ directives = []
105
+
106
+ # Determine public/private caching
107
+ if any(private in path for private in self.cache_config.private_paths):
108
+ directives.append("private")
109
+ else:
110
+ directives.append("public")
111
+
112
+ # Add max-age directives
113
+ directives.append(f"max-age={self.cache_config.max_age}")
114
+
115
+ if self.cache_config.s_maxage is not None:
116
+ directives.append(f"s-maxage={self.cache_config.s_maxage}")
117
+
118
+ if self.cache_config.stale_while_revalidate is not None:
119
+ directives.append(f"stale-while-revalidate={self.cache_config.stale_while_revalidate}")
120
+
121
+ if self.cache_config.stale_if_error is not None:
122
+ directives.append(f"stale-if-error={self.cache_config.stale_if_error}")
123
+
124
+ # Add custom cache control directives
125
+ directives.extend(self.cache_config.cache_control)
126
+
127
+ return ", ".join(directives)
128
+
129
+ def cleanup_context(self, context_id: str):
130
+ try:
131
+ del self.request_context[context_id]
132
+ except Exception:
133
+ pass
134
+
135
+ def before_request(self, request: Request) -> Request | Response:
136
+ """Handle conditional requests"""
137
+ if not self._should_cache(request, request.path):
138
+ return request
139
+
140
+ cache_key = self._generate_cache_key(request)
141
+ etag = self._etag_cache.get(cache_key)
142
+
143
+ if etag:
144
+ if_none_match = request.headers.get("if-none-match")
145
+ if if_none_match and if_none_match == etag:
146
+ return Response(status_code=304, description=b"", headers=Header({"ETag": etag}))
147
+ self.request_context[request.context_id] = request
148
+ return request
149
+
150
+ def after_request(self, response: Response) -> Response:
151
+ """Add caching headers to response"""
152
+ request = self.request_context.get(response.context_id)
153
+ self.cleanup_context(response.context_id)
154
+ if not self._should_cache(request, request.path):
155
+ response.headers.set("Cache-Control", "no-store")
156
+ return response
157
+
158
+ # Generate and store ETag
159
+ cache_key = self._generate_cache_key(request)
160
+ etag = self._generate_etag(response)
161
+ self._etag_cache[cache_key] = etag
162
+
163
+ # Set cache headers
164
+ response.headers.update(
165
+ {
166
+ "Cache-Control": self._build_cache_control(request.path),
167
+ "ETag": etag,
168
+ "Vary": ", ".join(self.cache_config.vary_by),
169
+ "Last-Modified": datetime.now(tz=timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT"),
170
+ }
171
+ )
172
+
173
+ # Add CDN-specific headers
174
+ response.headers.set("CDN-Cache-Control", response.headers["Cache-Control"])
175
+ response.headers.set("Surrogate-Control", f"max-age={self.cache_config.s_maxage or self.cache_config.max_age}")
176
+
177
+ return response
@@ -0,0 +1,78 @@
1
+ import gzip
2
+ import zlib
3
+ from typing import List, Optional
4
+
5
+ from hypern.hypern import Request, Response
6
+
7
+ from .base import Middleware, MiddlewareConfig
8
+
9
+
10
+ class CompressionMiddleware(Middleware):
11
+ """
12
+ Middleware for compressing response content using gzip or deflate encoding.
13
+ """
14
+
15
+ def __init__(
16
+ self, config: Optional[MiddlewareConfig] = None, min_size: int = 500, compression_level: int = 6, include_types: Optional[List[str]] = None
17
+ ) -> None:
18
+ """
19
+ Initialize compression middleware.
20
+
21
+ Args:
22
+ min_size: Minimum response size in bytes to trigger compression
23
+ compression_level: Compression level (1-9, higher = better compression but slower)
24
+ include_types: List of content types to compress (defaults to common text types)
25
+ """
26
+ super().__init__(config)
27
+ self.min_size = min_size
28
+ self.compression_level = compression_level
29
+ self.include_types = include_types or [
30
+ "text/plain",
31
+ "text/html",
32
+ "text/css",
33
+ "text/javascript",
34
+ "application/javascript",
35
+ "application/json",
36
+ "application/xml",
37
+ "application/x-yaml",
38
+ ]
39
+
40
+ def before_request(self, request: Request) -> Request:
41
+ return request
42
+
43
+ def after_request(self, response: Response) -> Response:
44
+ # Check if response should be compressed
45
+ content_type = (response.headers.get("content-type") or "").split(";")[0].lower()
46
+ content_encoding = (response.headers.get("content-encoding") or "").lower()
47
+
48
+ # Skip if:
49
+ # - Content is already encoded
50
+ # - Content type is not in include list
51
+ # - Content length is below minimum size
52
+ if content_encoding or content_type not in self.include_types or len(response.description.encode()) < self.min_size:
53
+ return response
54
+
55
+ # Get accepted encodings from request
56
+ accept_encoding = (response.headers.get("accept-encoding") or "").lower()
57
+
58
+ if "gzip" in accept_encoding:
59
+ # Use gzip compression
60
+ response.description = gzip.compress(
61
+ response.description if isinstance(response.description, bytes) else str(response.description).encode(), compresslevel=self.compression_level
62
+ )
63
+ response.headers.set("content-encoding", "gzip")
64
+
65
+ elif "deflate" in accept_encoding:
66
+ # Use deflate compression
67
+ response.description = zlib.compress(
68
+ response.description if isinstance(response.description, bytes) else str(response.description).encode(), level=self.compression_level
69
+ )
70
+ response.headers.set("content-encoding", "deflate")
71
+
72
+ # Update content length after compression
73
+ response.headers.set("content-length", str(len(response.description)))
74
+
75
+ # Add Vary header to indicate content varies by Accept-Encoding
76
+ response.headers.set("vary", "Accept-Encoding")
77
+
78
+ return response
hypern/middleware/cors.py CHANGED
@@ -1,5 +1,6 @@
1
- from typing import List
1
+ from typing import List, Optional
2
2
  from .base import Middleware
3
+ from hypern.hypern import MiddlewareConfig
3
4
 
4
5
 
5
6
  class CORSMiddleware(Middleware):
@@ -8,8 +9,10 @@ class CORSMiddleware(Middleware):
8
9
  methods, and headers.
9
10
  """
10
11
 
11
- def __init__(self, allow_origins: List[str] = None, allow_methods: List[str] = None, allow_headers: List[str] = None) -> None:
12
- super().__init__()
12
+ def __init__(
13
+ self, config: Optional[MiddlewareConfig] = None, allow_origins: List[str] = None, allow_methods: List[str] = None, allow_headers: List[str] = None
14
+ ) -> None:
15
+ super().__init__(config)
13
16
  self.allow_origins = allow_origins or []
14
17
  self.allow_methods = allow_methods or []
15
18
  self.allow_headers = allow_headers or []
@@ -1,10 +1,11 @@
1
+ from typing import Optional
1
2
  import time
2
3
  from abc import ABC, abstractmethod
3
4
  from threading import Lock
4
5
 
5
6
  from hypern.hypern import Request, Response
6
7
 
7
- from .base import Middleware
8
+ from .base import Middleware, MiddlewareConfig
8
9
 
9
10
 
10
11
  class StorageBackend(ABC):
@@ -102,14 +103,14 @@ class RateLimitMiddleware(Middleware):
102
103
  Requests per minute for a given IP address.
103
104
  """
104
105
 
105
- def __init__(self, storage_backend, requests_per_minute=60, window_size=60):
106
- super().__init__()
106
+ def __init__(self, storage_backend, config: Optional[MiddlewareConfig] = None, requests_per_minute=60, window_size=60):
107
+ super().__init__(config)
107
108
  self.storage = storage_backend
108
109
  self.requests_per_minute = requests_per_minute
109
110
  self.window_size = window_size
110
111
 
111
112
  def get_request_identifier(self, request: Request):
112
- return request.ip_addr
113
+ return request.remote_addr
113
114
 
114
115
  def before_request(self, request: Request):
115
116
  """