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.
Files changed (64) hide show
  1. future/__init__.py +3 -0
  2. future/application.py +656 -0
  3. future/authentication/Auth0Authentication.py +9 -0
  4. future/authentication/Authentication.py +11 -0
  5. future/authentication/AzureADAuthentication.py +9 -0
  6. future/authentication/BasicAuthentication.py +9 -0
  7. future/authentication/KerberosAuthentication.py +9 -0
  8. future/authentication/KeycloakAuthentication.py +9 -0
  9. future/authentication/OAuth2Authentication.py +9 -0
  10. future/authentication/OpenIdConnectAuthentication.py +9 -0
  11. future/authentication/SAMLAuthentication.py +9 -0
  12. future/cli/__init__.py +1 -0
  13. future/cli/main.py +608 -0
  14. future/cli/stubs.py +114 -0
  15. future/controllers/__init__.py +4 -0
  16. future/controllers/base.py +8 -0
  17. future/controllers/builtins.py +41 -0
  18. future/controllers/graphql.py +23 -0
  19. future/controllers/openapi.py +224 -0
  20. future/databases/Clickhouse.py +176 -0
  21. future/databases/Connections.py +20 -0
  22. future/databases/Database.py +66 -0
  23. future/databases/Elasticsearch.py +146 -0
  24. future/databases/MongoDB.py +177 -0
  25. future/databases/MySQL.py +215 -0
  26. future/databases/Postgres.py +214 -0
  27. future/databases/Redis.py +146 -0
  28. future/databases/SQLite.py +219 -0
  29. future/exceptions.py +27 -0
  30. future/graphql/__init__.py +1 -0
  31. future/graphql/schema.py +148 -0
  32. future/lifespan.py +70 -0
  33. future/logger.py +53 -0
  34. future/middleware/Middleware.py +201 -0
  35. future/middleware/SessionMiddleware.py +77 -0
  36. future/middleware/__init__.py +21 -0
  37. future/migrations/Blueprint.py +54 -0
  38. future/migrations/Column.py +33 -0
  39. future/migrations/Migration.py +11 -0
  40. future/migrations/MigrationGenerator.py +137 -0
  41. future/migrations/Migrator.py +72 -0
  42. future/migrations/Schema.py +21 -0
  43. future/models/__init__.py +1 -0
  44. future/models/model.py +224 -0
  45. future/openapi.py +233 -0
  46. future/plugins/ElasticsearchPlugin.py +496 -0
  47. future/plugins/__init__.py +9 -0
  48. future/request.py +143 -0
  49. future/response.py +201 -0
  50. future/routing.py +342 -0
  51. future/seeds/SeedGenerator.py +95 -0
  52. future/seeds/SeedRunner.py +42 -0
  53. future/seeds/Seeder.py +9 -0
  54. future/settings.py +94 -0
  55. future/tasks/__init__.py +14 -0
  56. future/tasks/scheduler.py +267 -0
  57. future/testing/__init__.py +1 -0
  58. future/testing/client.py +135 -0
  59. future/types.py +47 -0
  60. future_framework-1.1.0.dist-info/METADATA +68 -0
  61. future_framework-1.1.0.dist-info/RECORD +64 -0
  62. future_framework-1.1.0.dist-info/WHEEL +4 -0
  63. future_framework-1.1.0.dist-info/entry_points.txt +3 -0
  64. future_framework-1.1.0.dist-info/licenses/LICENSE +21 -0
future/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("future-framework")
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
+ )
@@ -0,0 +1,9 @@
1
+ """
2
+ Auth0 authentication.
3
+ """
4
+
5
+ from future.authentication.Authentication import Authentication
6
+
7
+
8
+ class Auth0Authentication(Authentication):
9
+ auth_type = "oauth"
@@ -0,0 +1,11 @@
1
+ """
2
+ Base authentication interface.
3
+ """
4
+
5
+
6
+ class Authentication:
7
+ auth_type: str = ""
8
+
9
+
10
+ # Alias for stubs that subclass BaseAuthentication (Azure AD, Kerberos, …).
11
+ BaseAuthentication = Authentication