future-framework 1.1.0__py3-none-any.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.
- future/__init__.py +3 -0
- future/application.py +656 -0
- future/authentication/Auth0Authentication.py +9 -0
- future/authentication/Authentication.py +11 -0
- future/authentication/AzureADAuthentication.py +9 -0
- future/authentication/BasicAuthentication.py +9 -0
- future/authentication/KerberosAuthentication.py +9 -0
- future/authentication/KeycloakAuthentication.py +9 -0
- future/authentication/OAuth2Authentication.py +9 -0
- future/authentication/OpenIdConnectAuthentication.py +9 -0
- future/authentication/SAMLAuthentication.py +9 -0
- future/cli/__init__.py +1 -0
- future/cli/main.py +608 -0
- future/cli/stubs.py +114 -0
- future/controllers/__init__.py +4 -0
- future/controllers/base.py +8 -0
- future/controllers/builtins.py +41 -0
- future/controllers/graphql.py +23 -0
- future/controllers/openapi.py +224 -0
- future/databases/Clickhouse.py +176 -0
- future/databases/Connections.py +20 -0
- future/databases/Database.py +66 -0
- future/databases/Elasticsearch.py +146 -0
- future/databases/MongoDB.py +177 -0
- future/databases/MySQL.py +215 -0
- future/databases/Postgres.py +214 -0
- future/databases/Redis.py +146 -0
- future/databases/SQLite.py +219 -0
- future/exceptions.py +27 -0
- future/graphql/__init__.py +1 -0
- future/graphql/schema.py +148 -0
- future/lifespan.py +70 -0
- future/logger.py +53 -0
- future/middleware/Middleware.py +201 -0
- future/middleware/SessionMiddleware.py +77 -0
- future/middleware/__init__.py +21 -0
- future/migrations/Blueprint.py +54 -0
- future/migrations/Column.py +33 -0
- future/migrations/Migration.py +11 -0
- future/migrations/MigrationGenerator.py +137 -0
- future/migrations/Migrator.py +72 -0
- future/migrations/Schema.py +21 -0
- future/models/__init__.py +1 -0
- future/models/model.py +224 -0
- future/openapi.py +233 -0
- future/plugins/ElasticsearchPlugin.py +496 -0
- future/plugins/__init__.py +9 -0
- future/request.py +143 -0
- future/response.py +201 -0
- future/routing.py +342 -0
- future/seeds/SeedGenerator.py +95 -0
- future/seeds/SeedRunner.py +42 -0
- future/seeds/Seeder.py +9 -0
- future/settings.py +94 -0
- future/tasks/__init__.py +14 -0
- future/tasks/scheduler.py +267 -0
- future/testing/__init__.py +1 -0
- future/testing/client.py +135 -0
- future/types.py +47 -0
- future_framework-1.1.0.dist-info/METADATA +68 -0
- future_framework-1.1.0.dist-info/RECORD +64 -0
- future_framework-1.1.0.dist-info/WHEEL +4 -0
- future_framework-1.1.0.dist-info/entry_points.txt +3 -0
- future_framework-1.1.0.dist-info/licenses/LICENSE +21 -0
future/__init__.py
ADDED
future/application.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import sys
|
|
3
|
+
import logging
|
|
4
|
+
import traceback
|
|
5
|
+
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
from typing import Any, Optional, Union
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from rich.box import ROUNDED
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from future.exceptions import ErrorHandler, HTTPException
|
|
19
|
+
from future.logger import log
|
|
20
|
+
from future.middleware import Middleware
|
|
21
|
+
from future.openapi import get_openapi_config, is_docs_path, openapi_routes, rebuild_spec_from_routes, set_openapi_config, spec_path
|
|
22
|
+
from future.request import Request
|
|
23
|
+
from future.response import Response, WebSocketResponse
|
|
24
|
+
from future.routing import Route, RouteGroup
|
|
25
|
+
from future.types import AsgiEventType, ASGIReceive, ASGIScope, ASGISend, RouteConfig
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
# ASGI SPEC
|
|
30
|
+
async def application(scope, receive, send):
|
|
31
|
+
event = await receive()
|
|
32
|
+
...
|
|
33
|
+
await send({"type": "websocket.send", ...: ...})
|
|
34
|
+
|
|
35
|
+
example_http_event = {
|
|
36
|
+
"type": "http.request",
|
|
37
|
+
"body": b"Hello World",
|
|
38
|
+
"more_body": False,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
example_websocket_event = {
|
|
42
|
+
"type": "websocket.send",
|
|
43
|
+
"text": "Hello world!",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
example_http_scope = {
|
|
47
|
+
'type': 'http',
|
|
48
|
+
'method': 'POST',
|
|
49
|
+
'path': '/echo',
|
|
50
|
+
'headers': [...],
|
|
51
|
+
...: ...,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
except BaseException:
|
|
59
|
+
event_type = "lifespan.shutdown.failed" if started else "lifespan.startup.failed"
|
|
60
|
+
await send({"type": event_type, "message": traceback.format_exc()})
|
|
61
|
+
raise
|
|
62
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
63
|
+
|
|
64
|
+
# Custom openapi path
|
|
65
|
+
if path == "/openapi.json":
|
|
66
|
+
await self.serve_openapi(send)
|
|
67
|
+
return
|
|
68
|
+
while True:
|
|
69
|
+
message = await receive()
|
|
70
|
+
print(f"Got message:", message)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Future:
|
|
75
|
+
def __init__(self, lifespan: Any, config: dict[str, Any] | None = None) -> None:
|
|
76
|
+
# spawn background tasks in the lifespan startup process... or database connections etc...
|
|
77
|
+
# on shutdown, save shit, send info about shutdown etc...
|
|
78
|
+
self.lifespan = lifespan
|
|
79
|
+
self.routes: dict[str, dict[str, RouteConfig]] = {}
|
|
80
|
+
self.config = config or {}
|
|
81
|
+
self.databases = self.config.get("DATABASES")
|
|
82
|
+
if self.databases:
|
|
83
|
+
from future.databases.Connections import Connections
|
|
84
|
+
Connections().set_connection_details(self.databases)
|
|
85
|
+
|
|
86
|
+
domain = self.config.get("APP_DOMAIN", "")
|
|
87
|
+
|
|
88
|
+
# Domainless mode: if no domain is set, subdomains are ignored, only prefixes work
|
|
89
|
+
# Domain defaults to empty string, so this will always be true
|
|
90
|
+
if domain == "":
|
|
91
|
+
warning_msg = (
|
|
92
|
+
"[WARNING] No domain specified!\n"
|
|
93
|
+
"Subdomains in RouteGroups will be IGNORED.\n"
|
|
94
|
+
"All routes will be accessible regardless of Host header.\n"
|
|
95
|
+
"Prefixes will still work as expected.\n"
|
|
96
|
+
"To enable subdomain routing, set the 'domain' parameter (e.g., domain='example.com')."
|
|
97
|
+
)
|
|
98
|
+
log.warning(warning_msg)
|
|
99
|
+
self.domain = ""
|
|
100
|
+
self.domainless_mode = True
|
|
101
|
+
else:
|
|
102
|
+
# Validate domain format (basic check)
|
|
103
|
+
if not domain.replace(".", "").replace("-", "").isalnum():
|
|
104
|
+
raise ValueError(f"Invalid domain format: {domain}. Domain must be alphanumeric with dots and hyphens only.")
|
|
105
|
+
self.domain = domain
|
|
106
|
+
self.domainless_mode = False
|
|
107
|
+
|
|
108
|
+
# Performance monitoring
|
|
109
|
+
self.route_count = 0
|
|
110
|
+
self.max_nesting_depth = 0
|
|
111
|
+
self.registered_domains: set[str] = set()
|
|
112
|
+
|
|
113
|
+
# Optimization: Cache for domain validation
|
|
114
|
+
self._domain_cache: dict[str, bool] = {}
|
|
115
|
+
set_openapi_config(self.config)
|
|
116
|
+
log.setLevel(logging.DEBUG if bool(self.config.get("APP_DEBUG")) else logging.INFO)
|
|
117
|
+
|
|
118
|
+
def set_config(self, config: dict[str, Any]) -> None:
|
|
119
|
+
self.config = config
|
|
120
|
+
set_openapi_config(self.config)
|
|
121
|
+
log.setLevel(logging.DEBUG if bool(self.config.get("APP_DEBUG")) else logging.INFO)
|
|
122
|
+
|
|
123
|
+
def _resolve_controller_action(self, endpoint: Any) -> tuple[Any, str | None]:
|
|
124
|
+
qual = getattr(endpoint, "__qualname__", "") or ""
|
|
125
|
+
mod_name = getattr(endpoint, "__module__", None)
|
|
126
|
+
if "." in qual and mod_name:
|
|
127
|
+
cls_name = qual.rsplit(".", 1)[0]
|
|
128
|
+
if "." not in cls_name:
|
|
129
|
+
mod = sys.modules.get(mod_name)
|
|
130
|
+
if mod is not None:
|
|
131
|
+
cls = getattr(mod, cls_name, None)
|
|
132
|
+
if cls is not None:
|
|
133
|
+
return cls, endpoint.__name__
|
|
134
|
+
return None, None
|
|
135
|
+
|
|
136
|
+
def _add_route(self, route: Route, subdomain: str = "", parent_middlewares: Optional[list[Middleware]] = None, group: Optional[dict[str, str]] = None) -> None:
|
|
137
|
+
"""Internal method to add single routes to the application.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
route (Route): The actual route object
|
|
141
|
+
subdomain (str): The full domain the route should be available for (e.g., "dev.api.example.com").
|
|
142
|
+
"""
|
|
143
|
+
# In domainless mode, always use the empty string key
|
|
144
|
+
key = "" if getattr(self, "domainless_mode", False) else subdomain
|
|
145
|
+
if key not in self.routes:
|
|
146
|
+
self.routes[key] = {}
|
|
147
|
+
self.route_count += 1
|
|
148
|
+
self.registered_domains.add(key)
|
|
149
|
+
|
|
150
|
+
middleware_classes: list[Any] = []
|
|
151
|
+
if parent_middlewares:
|
|
152
|
+
middleware_classes.extend(sorted(parent_middlewares, key=lambda m: m.priority))
|
|
153
|
+
middleware_classes.extend(sorted(route.middlewares, key=lambda m: m.priority))
|
|
154
|
+
|
|
155
|
+
controller_cls, action = self._resolve_controller_action(route.endpoint)
|
|
156
|
+
|
|
157
|
+
route_config = RouteConfig(
|
|
158
|
+
handler=route.endpoint,
|
|
159
|
+
controller=controller_cls,
|
|
160
|
+
action=action,
|
|
161
|
+
middleware={"classes": middleware_classes},
|
|
162
|
+
regex={"paths": [route._rx]} if hasattr(route, "_rx") else None, # type: ignore[reportGeneralTypeIssues]
|
|
163
|
+
methods=route.methods,
|
|
164
|
+
route=route, # FIXME: Added in temporarily because of a regression in the regex matching causing /users/123/test to not match /users/123/test/. Not sure when this happened. This shouldnt be needed.
|
|
165
|
+
group=group or {"name": "", "prefix": "", "subdomain": ""},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
self._check_route_conflicts(route, key)
|
|
169
|
+
# Path + methods so Get("/x") and Post("/x") can coexist
|
|
170
|
+
self.routes[key][f"{','.join(route.methods)} {route.path}"] = route_config
|
|
171
|
+
|
|
172
|
+
def has_path(self, path: str) -> bool:
|
|
173
|
+
for route_map in self.routes.values():
|
|
174
|
+
for route_config in route_map.values():
|
|
175
|
+
route = route_config.get("route")
|
|
176
|
+
if route is not None and getattr(route, "path", None) == path:
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def add_routes(self, routes: Sequence[Union[Route, RouteGroup]]) -> None:
|
|
181
|
+
for r in routes:
|
|
182
|
+
if isinstance(r, Route):
|
|
183
|
+
r.compile_pattern()
|
|
184
|
+
# Use the actual domain for individual routes, just like RouteGroups
|
|
185
|
+
subdomain = self.domain if not getattr(self, "domainless_mode", False) else ""
|
|
186
|
+
self._add_route(route=r, subdomain=subdomain)
|
|
187
|
+
elif isinstance(r, RouteGroup): # type: ignore[reportUnnecessaryIsInstance]
|
|
188
|
+
self._add_route_group(r, parent_subdomain="", parent_middlewares=[], nesting_depth=0)
|
|
189
|
+
else:
|
|
190
|
+
raise NotImplementedError
|
|
191
|
+
self._maybe_auto_openapi_routes()
|
|
192
|
+
rebuild_spec_from_routes(self.routes, self.config)
|
|
193
|
+
|
|
194
|
+
def _maybe_auto_openapi_routes(self) -> None:
|
|
195
|
+
openapi = get_openapi_config(self.config)
|
|
196
|
+
if not openapi.get("auto_routes") or not openapi.get("enabled", True):
|
|
197
|
+
return
|
|
198
|
+
if self.has_path(spec_path()):
|
|
199
|
+
return
|
|
200
|
+
subdomain = self.domain if not getattr(self, "domainless_mode", False) else ""
|
|
201
|
+
prefix = openapi.get("path_prefix") or ""
|
|
202
|
+
for route in openapi_routes(config=self.config):
|
|
203
|
+
if prefix:
|
|
204
|
+
joined = prefix if (not route.path or route.path == "/") else f"{prefix.rstrip('/')}{route.path}"
|
|
205
|
+
route = Route(methods=list(route.methods), path=joined, endpoint=route.endpoint, name=route.name, strict_slashes=route.strict_slashes, middlewares=list(route.middlewares), scopes=list(route.scopes))
|
|
206
|
+
route.compile_pattern()
|
|
207
|
+
self._add_route(route=route, subdomain=subdomain)
|
|
208
|
+
|
|
209
|
+
def _add_route_group(
|
|
210
|
+
self,
|
|
211
|
+
route_group: RouteGroup,
|
|
212
|
+
parent_subdomain: str = "",
|
|
213
|
+
parent_prefix: str = "",
|
|
214
|
+
parent_middlewares: Optional[list[Middleware]] = None,
|
|
215
|
+
nesting_depth: int = 0,
|
|
216
|
+
parent_group_names: Optional[list[str]] = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Recursively add RouteGroup and its nested RouteGroups."""
|
|
219
|
+
# Validate RouteGroup configuration
|
|
220
|
+
self._validate_route_group(route_group)
|
|
221
|
+
|
|
222
|
+
# Track nesting depth for performance monitoring
|
|
223
|
+
self.max_nesting_depth = max(self.max_nesting_depth, nesting_depth)
|
|
224
|
+
|
|
225
|
+
# Build the full subdomain path for this group
|
|
226
|
+
current_subdomain = route_group.subdomain
|
|
227
|
+
if parent_subdomain and current_subdomain:
|
|
228
|
+
full_subdomain = f"{current_subdomain}.{parent_subdomain}"
|
|
229
|
+
elif parent_subdomain:
|
|
230
|
+
full_subdomain = parent_subdomain
|
|
231
|
+
elif current_subdomain:
|
|
232
|
+
full_subdomain = current_subdomain
|
|
233
|
+
else:
|
|
234
|
+
full_subdomain = ""
|
|
235
|
+
|
|
236
|
+
# Build the full prefix path for this group with validation
|
|
237
|
+
full_prefix = self._build_prefix_path(parent_prefix, route_group.prefix)
|
|
238
|
+
|
|
239
|
+
group_names = list(parent_group_names or [])
|
|
240
|
+
if route_group.name:
|
|
241
|
+
group_names.append(route_group.name)
|
|
242
|
+
group_meta = {
|
|
243
|
+
"name": " › ".join(group_names) if group_names else "",
|
|
244
|
+
"prefix": full_prefix,
|
|
245
|
+
"subdomain": full_subdomain,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Outer group middleware + this group's (nested groups inherit the full chain)
|
|
249
|
+
accumulated_middlewares = list(parent_middlewares or []) + list(route_group.middlewares)
|
|
250
|
+
|
|
251
|
+
# Process all routes in this group
|
|
252
|
+
for r in route_group.routes:
|
|
253
|
+
if isinstance(r, RouteGroup):
|
|
254
|
+
# Nested RouteGroup - recurse with updated parent subdomain and prefix
|
|
255
|
+
self._add_route_group(
|
|
256
|
+
r,
|
|
257
|
+
parent_subdomain=full_subdomain,
|
|
258
|
+
parent_prefix=full_prefix,
|
|
259
|
+
parent_middlewares=accumulated_middlewares,
|
|
260
|
+
nesting_depth=nesting_depth + 1,
|
|
261
|
+
parent_group_names=group_names,
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
# Copy before prefixing — never mutate caller Route objects (reload would stack /api → /api/api).
|
|
265
|
+
# prefix="/indexes" + path="/" must be "/indexes", not "/indexes/" (that broke matching as ^/indexes//?$).
|
|
266
|
+
if not r.path or r.path == "/":
|
|
267
|
+
joined = full_prefix or "/"
|
|
268
|
+
else:
|
|
269
|
+
joined = f"{full_prefix.rstrip('/')}{r.path}" if full_prefix else r.path
|
|
270
|
+
prefixed = Route(methods=list(r.methods), path=joined, endpoint=r.endpoint, name=r.name, strict_slashes=r.strict_slashes, middlewares=list(r.middlewares), scopes=list(r.scopes))
|
|
271
|
+
prefixed.compile_pattern()
|
|
272
|
+
if is_docs_path(r.path) or is_docs_path(prefixed.path):
|
|
273
|
+
set_openapi_config({"OPENAPI": {"path_prefix": full_prefix}})
|
|
274
|
+
|
|
275
|
+
# Build full domain path for dictionary lookup
|
|
276
|
+
if getattr(self, "domainless_mode", False):
|
|
277
|
+
full_domain = ""
|
|
278
|
+
else:
|
|
279
|
+
full_domain = f"{full_subdomain}.{self.domain}" if full_subdomain else self.domain
|
|
280
|
+
|
|
281
|
+
self._add_route(route=prefixed, subdomain=full_domain, parent_middlewares=accumulated_middlewares, group=group_meta)
|
|
282
|
+
|
|
283
|
+
def _validate_route_group(self, route_group: RouteGroup) -> None:
|
|
284
|
+
"""Validate RouteGroup configuration."""
|
|
285
|
+
# Validate subdomain format
|
|
286
|
+
if route_group.subdomain and not route_group.subdomain.replace(".", "").replace("-", "").isalnum():
|
|
287
|
+
raise ValueError(f"Invalid subdomain format: {route_group.subdomain}. Must be alphanumeric with dots and hyphens only.")
|
|
288
|
+
|
|
289
|
+
# Validate prefix format
|
|
290
|
+
if route_group.prefix:
|
|
291
|
+
if not route_group.prefix.startswith("/"):
|
|
292
|
+
raise ValueError(f"Invalid prefix format: {route_group.prefix}. Must start with '/'.")
|
|
293
|
+
if "//" in route_group.prefix:
|
|
294
|
+
raise ValueError(f"Invalid prefix format: {route_group.prefix}. Cannot contain consecutive slashes.")
|
|
295
|
+
|
|
296
|
+
def _build_prefix_path(self, parent_prefix: str, current_prefix: str) -> str:
|
|
297
|
+
"""Build and validate accumulated prefix path."""
|
|
298
|
+
if parent_prefix and current_prefix:
|
|
299
|
+
full_prefix = f"{parent_prefix}{current_prefix}"
|
|
300
|
+
elif parent_prefix:
|
|
301
|
+
full_prefix = parent_prefix
|
|
302
|
+
elif current_prefix:
|
|
303
|
+
full_prefix = current_prefix
|
|
304
|
+
else:
|
|
305
|
+
full_prefix = ""
|
|
306
|
+
|
|
307
|
+
# Validate final prefix
|
|
308
|
+
if full_prefix and not full_prefix.startswith("/"):
|
|
309
|
+
raise ValueError(f"Invalid accumulated prefix: {full_prefix}. Must start with '/'.")
|
|
310
|
+
|
|
311
|
+
return full_prefix
|
|
312
|
+
|
|
313
|
+
def _check_route_conflicts(self, route: Route, domain: str) -> None:
|
|
314
|
+
"""Conflict only when the same path shares an HTTP method."""
|
|
315
|
+
existing_routes = self.routes.get(domain, {})
|
|
316
|
+
for existing in existing_routes.values():
|
|
317
|
+
other = existing.get("route")
|
|
318
|
+
if other is None or getattr(other, "path", None) != route.path:
|
|
319
|
+
continue
|
|
320
|
+
overlap = set(other.methods) & set(route.methods)
|
|
321
|
+
if overlap:
|
|
322
|
+
raise ValueError(f"Route conflict detected: {','.join(sorted(overlap))} {route.path} already exists in domain {domain}")
|
|
323
|
+
|
|
324
|
+
def _validate_domain_access(self, host_domain: str) -> bool:
|
|
325
|
+
"""Validate if the host domain is allowed to access routes."""
|
|
326
|
+
compare_host = host_domain
|
|
327
|
+
debug = bool(self.config.get("APP_DEBUG")) if self.config else False
|
|
328
|
+
if debug and ":" in compare_host:
|
|
329
|
+
# APP_DEBUG only: Host: localhost:8000 → localhost (explicit Host without port in prod)
|
|
330
|
+
compare_host = compare_host.rsplit(":", 1)[0]
|
|
331
|
+
log.debug(f"Validating domain access for host: '{host_domain}' (compare='{compare_host}') against configured domain: '{self.domain}'")
|
|
332
|
+
|
|
333
|
+
# Optimization: Check cache first
|
|
334
|
+
if compare_host in self._domain_cache:
|
|
335
|
+
return self._domain_cache[compare_host]
|
|
336
|
+
|
|
337
|
+
# Check if domain matches our configured domain or any subdomain
|
|
338
|
+
if not self.domain:
|
|
339
|
+
self._domain_cache[compare_host] = True
|
|
340
|
+
return True # No domain restriction
|
|
341
|
+
|
|
342
|
+
# Allow exact domain match
|
|
343
|
+
if compare_host == self.domain:
|
|
344
|
+
self._domain_cache[compare_host] = True
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
# Allow subdomains of our domain
|
|
348
|
+
if compare_host.endswith(f".{self.domain}"):
|
|
349
|
+
self._domain_cache[compare_host] = True
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
self._domain_cache[compare_host] = False
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
def get_performance_stats(self) -> dict[str, Any]:
|
|
356
|
+
"""Get performance statistics for the application."""
|
|
357
|
+
return {
|
|
358
|
+
"total_routes": self.route_count,
|
|
359
|
+
"registered_domains": len(self.registered_domains),
|
|
360
|
+
"max_nesting_depth": self.max_nesting_depth,
|
|
361
|
+
"domain_list": list(self.registered_domains),
|
|
362
|
+
"memory_usage": {"routes_dict_size": len(self.routes), "total_route_configs": sum(len(configs) for configs in self.routes.values())},
|
|
363
|
+
"domain_cache_size": len(self._domain_cache),
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async def handle_lifespan_request(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None:
|
|
367
|
+
# initialize_scheduler for cronjobs
|
|
368
|
+
assert scope["type"] == "lifespan"
|
|
369
|
+
message = await receive()
|
|
370
|
+
assert message["type"] == AsgiEventType.LIFESPAN_STARTUP
|
|
371
|
+
app = scope.get("app")
|
|
372
|
+
|
|
373
|
+
started = False
|
|
374
|
+
try:
|
|
375
|
+
# async with self.lifespan(app) as state:
|
|
376
|
+
self.lifespan.app = app
|
|
377
|
+
async with self.lifespan as state:
|
|
378
|
+
if state is not None:
|
|
379
|
+
scope.setdefault("state", {}).update(state)
|
|
380
|
+
await send({"type": AsgiEventType.LIFESPAN_STARTUP_COMPLETE})
|
|
381
|
+
started = True
|
|
382
|
+
message = await receive()
|
|
383
|
+
assert message["type"] == AsgiEventType.LIFESPAN_SHUTDOWN
|
|
384
|
+
|
|
385
|
+
except BaseException:
|
|
386
|
+
event_type = AsgiEventType.LIFESPAN_SHUTDOWN_FAILED if started else AsgiEventType.LIFESPAN_STARTUP_FAILED
|
|
387
|
+
await send({"type": event_type, "message": traceback.format_exc()})
|
|
388
|
+
raise
|
|
389
|
+
|
|
390
|
+
await send({"type": AsgiEventType.LIFESPAN_SHUTDOWN_COMPLETE})
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
async def handle_http_request(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None:
|
|
394
|
+
request = Request(scope, receive)
|
|
395
|
+
response = Response()
|
|
396
|
+
try:
|
|
397
|
+
host_domain = request.host.split("/")[0] if "/" in request.host else request.host
|
|
398
|
+
if not self._validate_domain_access(host_domain):
|
|
399
|
+
raise HTTPException("Forbidden", 403)
|
|
400
|
+
|
|
401
|
+
debug = bool(self.config.get("APP_DEBUG")) if self.config else False
|
|
402
|
+
route_host = host_domain.rsplit(":", 1)[0] if debug and ":" in host_domain else host_domain
|
|
403
|
+
key = "" if getattr(self, "domainless_mode", False) else route_host
|
|
404
|
+
domain_routes = self.routes.get(key, {})
|
|
405
|
+
|
|
406
|
+
matched_route = None
|
|
407
|
+
route_params = None
|
|
408
|
+
allowed_methods: list[str] = []
|
|
409
|
+
|
|
410
|
+
for route_path, route_config in domain_routes.items():
|
|
411
|
+
route = route_config.get("route", None)
|
|
412
|
+
if route and hasattr(route, "match"):
|
|
413
|
+
if getattr(route, "_rx", None) is not None and route._rx.match(request.path.encode()):
|
|
414
|
+
allowed_methods.extend(route.methods)
|
|
415
|
+
route_match = route.match(request.method, request.path.encode())
|
|
416
|
+
if route_match:
|
|
417
|
+
matched_route = route_config
|
|
418
|
+
route_params = route_match.params
|
|
419
|
+
break
|
|
420
|
+
else:
|
|
421
|
+
cfg_path = route_path.split(" ", 1)[-1] if " " in str(route_path) else route_path
|
|
422
|
+
if cfg_path == request.path:
|
|
423
|
+
matched_route = route_config
|
|
424
|
+
break
|
|
425
|
+
if not matched_route:
|
|
426
|
+
if allowed_methods:
|
|
427
|
+
if request.method == "OPTIONS":
|
|
428
|
+
for route_path, route_config in domain_routes.items():
|
|
429
|
+
route = route_config.get("route", None)
|
|
430
|
+
if not route or getattr(route, "_rx", None) is None or not route._rx.match(request.path.encode()):
|
|
431
|
+
continue
|
|
432
|
+
middleware_classes = route_config.get("middleware", {}).get("classes") or []
|
|
433
|
+
if any(getattr(middleware, "__name__", "") == "CORSMiddleware" for middleware in middleware_classes):
|
|
434
|
+
matched_route = route_config
|
|
435
|
+
break
|
|
436
|
+
if not matched_route:
|
|
437
|
+
raise HTTPException("Method Not Allowed", 405, headers={"allow": ", ".join(sorted(set(allowed_methods)))})
|
|
438
|
+
if not matched_route:
|
|
439
|
+
raise HTTPException("Not Found", 404)
|
|
440
|
+
|
|
441
|
+
request.route = matched_route.get("route")
|
|
442
|
+
middleware_classes = matched_route["middleware"].get("classes") or []
|
|
443
|
+
middleware_instances = [m(request, response) for m in middleware_classes]
|
|
444
|
+
for mw in middleware_instances:
|
|
445
|
+
early = await mw.before()
|
|
446
|
+
if early is not None:
|
|
447
|
+
await early(send)
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
controller_cls = matched_route.get("controller")
|
|
451
|
+
action = matched_route.get("action")
|
|
452
|
+
if controller_cls and action:
|
|
453
|
+
ctrl = controller_cls(request, response)
|
|
454
|
+
method = getattr(ctrl, action)
|
|
455
|
+
if route_params:
|
|
456
|
+
result = await method(**route_params)
|
|
457
|
+
else:
|
|
458
|
+
result = await method()
|
|
459
|
+
else:
|
|
460
|
+
handler = matched_route["handler"]
|
|
461
|
+
if route_params:
|
|
462
|
+
result = await handler(request, response, **route_params)
|
|
463
|
+
else:
|
|
464
|
+
result = await handler(request, response)
|
|
465
|
+
if result is not None:
|
|
466
|
+
if not isinstance(result, Response):
|
|
467
|
+
raise TypeError(f"Controller must return a Response or None, got {type(result).__name__}")
|
|
468
|
+
response = result
|
|
469
|
+
|
|
470
|
+
for mw in reversed(middleware_instances):
|
|
471
|
+
modified = await mw.after()
|
|
472
|
+
if modified is not None:
|
|
473
|
+
response = modified
|
|
474
|
+
await response(send)
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
if not isinstance(exc, HTTPException):
|
|
477
|
+
log.exception("Unhandled exception while processing %s %s", request.method, request.path)
|
|
478
|
+
await ErrorHandler(request, response).handle(exc)(send)
|
|
479
|
+
|
|
480
|
+
async def handle_websocket_request(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None:
|
|
481
|
+
"""Handle WebSocket requests following the same pattern as HTTP requests."""
|
|
482
|
+
request_path = scope["path"].encode()
|
|
483
|
+
host_domain = ""
|
|
484
|
+
|
|
485
|
+
headers = dict(scope.get("headers", []))
|
|
486
|
+
host_header = headers.get(b"host", b"").decode()
|
|
487
|
+
if host_header:
|
|
488
|
+
host_domain = host_header.split("/")[0] if "/" in host_header else host_header
|
|
489
|
+
|
|
490
|
+
if not self._validate_domain_access(host_domain):
|
|
491
|
+
await send({"type": "websocket.close", "code": 1008, "reason": "Forbidden"})
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
debug = bool(self.config.get("APP_DEBUG")) if self.config else False
|
|
495
|
+
route_host = host_domain.rsplit(":", 1)[0] if debug and ":" in host_domain else host_domain
|
|
496
|
+
key = "" if getattr(self, "domainless_mode", False) else route_host
|
|
497
|
+
domain_routes = self.routes.get(key, {})
|
|
498
|
+
matched_route = None
|
|
499
|
+
route_params = None
|
|
500
|
+
|
|
501
|
+
for route_path, route_config in domain_routes.items():
|
|
502
|
+
route = route_config.get("route")
|
|
503
|
+
if route and hasattr(route, "match"):
|
|
504
|
+
route_match = route.match("WEBSOCKET", request_path)
|
|
505
|
+
if route_match:
|
|
506
|
+
matched_route = route_config
|
|
507
|
+
route_params = route_match.params
|
|
508
|
+
break
|
|
509
|
+
else:
|
|
510
|
+
cfg_path = route_path.split(" ", 1)[-1] if " " in str(route_path) else route_path
|
|
511
|
+
if cfg_path == scope["path"]:
|
|
512
|
+
matched_route = route_config
|
|
513
|
+
break
|
|
514
|
+
|
|
515
|
+
if not matched_route:
|
|
516
|
+
await send({"type": "websocket.close", "code": 1008, "reason": "Not Found"})
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
request = Request(scope, receive)
|
|
520
|
+
response = Response()
|
|
521
|
+
request.route = matched_route.get("route")
|
|
522
|
+
try:
|
|
523
|
+
middleware_classes = matched_route["middleware"].get("classes") or []
|
|
524
|
+
middleware_instances = [m(request, response) for m in middleware_classes]
|
|
525
|
+
for mw in middleware_instances:
|
|
526
|
+
early = await mw.before()
|
|
527
|
+
if early is not None:
|
|
528
|
+
await send({"type": "websocket.close", "code": 1008, "reason": "Middleware rejected"})
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
controller_cls = matched_route.get("controller")
|
|
532
|
+
action = matched_route.get("action")
|
|
533
|
+
if controller_cls and action:
|
|
534
|
+
ctrl = controller_cls(request, response)
|
|
535
|
+
method = getattr(ctrl, action)
|
|
536
|
+
if route_params:
|
|
537
|
+
result = await method(**route_params)
|
|
538
|
+
else:
|
|
539
|
+
result = await method()
|
|
540
|
+
else:
|
|
541
|
+
handler = matched_route["handler"]
|
|
542
|
+
if route_params:
|
|
543
|
+
result = await handler(request, response, **route_params)
|
|
544
|
+
else:
|
|
545
|
+
result = await handler(request, response)
|
|
546
|
+
if result is not None:
|
|
547
|
+
if not isinstance(result, (Response, WebSocketResponse)):
|
|
548
|
+
raise TypeError(f"Controller must return a Response, WebSocketResponse, or None, got {type(result).__name__}")
|
|
549
|
+
response = result
|
|
550
|
+
|
|
551
|
+
for mw in reversed(middleware_instances):
|
|
552
|
+
modified = await mw.after()
|
|
553
|
+
if modified is not None:
|
|
554
|
+
response = modified
|
|
555
|
+
|
|
556
|
+
await response(send)
|
|
557
|
+
except Exception:
|
|
558
|
+
log.exception("Unhandled exception while processing websocket %s", scope.get("path", ""))
|
|
559
|
+
await send({"type": "websocket.close", "code": 1011, "reason": "Internal Server Error"})
|
|
560
|
+
|
|
561
|
+
async def __call__(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None:
|
|
562
|
+
# Inject ourselves into the chain for later convenience
|
|
563
|
+
scope["app"] = self
|
|
564
|
+
|
|
565
|
+
# Check scope type and handle accordingly...
|
|
566
|
+
if scope["type"] == "lifespan":
|
|
567
|
+
log.debug("Received scope type: 'lifespan'")
|
|
568
|
+
log.debug("Handling lifespan stuff...")
|
|
569
|
+
await self.handle_lifespan_request(scope, receive, send)
|
|
570
|
+
elif scope["type"] == "http":
|
|
571
|
+
log.debug("Received scope type: 'http' request for path: %s", scope.get("path", ""))
|
|
572
|
+
await self.handle_http_request(scope, receive, send)
|
|
573
|
+
elif scope["type"] == "websocket":
|
|
574
|
+
log.debug("Received scope type: 'websocket' request for path: %s", scope.get("path", ""))
|
|
575
|
+
await self.handle_websocket_request(scope, receive, send)
|
|
576
|
+
else:
|
|
577
|
+
raise NotImplementedError
|
|
578
|
+
|
|
579
|
+
# FIXME: should this also be async?
|
|
580
|
+
def run(
|
|
581
|
+
self,
|
|
582
|
+
host: str = "127.0.0.1",
|
|
583
|
+
port: int = 8000,
|
|
584
|
+
workers: int = 4,
|
|
585
|
+
tls_key: Optional[str] = None,
|
|
586
|
+
tls_cert: Optional[str] = None,
|
|
587
|
+
tls_password: Optional[str] = None,
|
|
588
|
+
access_log: bool = True,
|
|
589
|
+
) -> None:
|
|
590
|
+
# Dynamically get system information
|
|
591
|
+
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
592
|
+
platform_info = platform.platform()
|
|
593
|
+
arch = platform.machine()
|
|
594
|
+
platform_str = f"{platform_info}-{arch}"
|
|
595
|
+
try:
|
|
596
|
+
future_version = version("future-framework")
|
|
597
|
+
except PackageNotFoundError:
|
|
598
|
+
future_version = "not installed"
|
|
599
|
+
except Exception:
|
|
600
|
+
future_version = "unknown"
|
|
601
|
+
|
|
602
|
+
debug = self.config.get("APP_DEBUG", False) if self.config else False
|
|
603
|
+
|
|
604
|
+
# ASCII-art "F" made with ✨ sparkles
|
|
605
|
+
left = Text()
|
|
606
|
+
left.append(" ✨✨✨✨✨ \n", style="bold yellow")
|
|
607
|
+
left.append(" ✨ \n", style="yellow")
|
|
608
|
+
left.append(" ✨✨✨✨ \n", style="bold yellow")
|
|
609
|
+
left.append(" ✨ \n", style="yellow")
|
|
610
|
+
left.append(" ✨ \n", style="yellow")
|
|
611
|
+
left.append(" \n", style="yellow")
|
|
612
|
+
left.append(" ASGI based Web API ", style="bold green")
|
|
613
|
+
|
|
614
|
+
# Right info column
|
|
615
|
+
right = Table.grid(padding=(0, 1))
|
|
616
|
+
right.add_column(justify="left", no_wrap=True)
|
|
617
|
+
right.add_column(justify="left")
|
|
618
|
+
right.add_row("[red]app:[/]", f"{ self.config.get('APP_NAME', 'Future') }")
|
|
619
|
+
right.add_row("[red]mode:[/]", f"{ "debug" if debug else "prod"} / { workers } worker(s)") # FIXME
|
|
620
|
+
right.add_row("[red]domain:[/]", f"{ self.config.get("APP_DOMAIN", "N/A") if self.config else "N/A" }")
|
|
621
|
+
right.add_row("[red]server:[/]", "future, HTTP/1.1")
|
|
622
|
+
right.add_row("[red]python:[/]", f"{ python_version }")
|
|
623
|
+
right.add_row("[red]platform:[/]", f"{ platform_str }")
|
|
624
|
+
right.add_row("[red]packages[/]:", f"future=={ future_version }")
|
|
625
|
+
# right.add_row("[red]docs:[/]", f"http://localhost:{port}/docs")
|
|
626
|
+
|
|
627
|
+
# Combined layout
|
|
628
|
+
layout = Table.grid(expand=False)
|
|
629
|
+
layout.add_column(ratio=1)
|
|
630
|
+
layout.add_column(ratio=2)
|
|
631
|
+
layout.add_row(left, right)
|
|
632
|
+
|
|
633
|
+
main_panel = Panel(layout, box=ROUNDED, padding=(1, 2), expand=False)
|
|
634
|
+
console = Console()
|
|
635
|
+
# console.print(title_panel)
|
|
636
|
+
console.print(main_panel)
|
|
637
|
+
|
|
638
|
+
# uvicorn needs an import string for reload / multiple workers; Future projects use run:app.
|
|
639
|
+
asgi_app: Any = self
|
|
640
|
+
reload = bool(debug)
|
|
641
|
+
if reload or workers > 1:
|
|
642
|
+
asgi_app = (self.config or {}).get("APP_ASGI", "run:app")
|
|
643
|
+
uvicorn.run(
|
|
644
|
+
app=asgi_app,
|
|
645
|
+
host=host,
|
|
646
|
+
port=port,
|
|
647
|
+
workers=workers,
|
|
648
|
+
reload=reload,
|
|
649
|
+
ssl_keyfile=tls_key,
|
|
650
|
+
ssl_certfile=tls_cert,
|
|
651
|
+
ssl_keyfile_password=tls_password,
|
|
652
|
+
timeout_graceful_shutdown=3,
|
|
653
|
+
log_level="info",
|
|
654
|
+
lifespan="on",
|
|
655
|
+
access_log=access_log,
|
|
656
|
+
)
|