forked from aegis/pyserveX
asgi/wsgi mounting implemented
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
ASGI Application Mount Module
|
||||
|
||||
This module provides functionality to mount external ASGI/WSGI applications
|
||||
(FastAPI, Flask, Django, etc.) at specified paths within PyServe.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Callable, cast
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ASGIAppLoader:
|
||||
def __init__(self):
|
||||
self._apps: Dict[str, ASGIApp] = {}
|
||||
self._wsgi_adapters: Dict[str, ASGIApp] = {}
|
||||
|
||||
def load_app(
|
||||
self,
|
||||
app_path: str,
|
||||
app_type: str = "asgi",
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
try:
|
||||
if module_path:
|
||||
module_dir = Path(module_path).resolve()
|
||||
if str(module_dir) not in sys.path:
|
||||
sys.path.insert(0, str(module_dir))
|
||||
logger.debug(f"Added {module_dir} to sys.path")
|
||||
|
||||
if ":" in app_path:
|
||||
module_name, attr_name = app_path.rsplit(":", 1)
|
||||
else:
|
||||
module_name = app_path
|
||||
attr_name = "app"
|
||||
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
app_or_factory = getattr(module, attr_name)
|
||||
|
||||
if factory:
|
||||
factory_args = factory_args or {}
|
||||
app = app_or_factory(**factory_args)
|
||||
logger.info(f"Created app from factory: {app_path}")
|
||||
else:
|
||||
app = app_or_factory
|
||||
logger.info(f"Loaded app: {app_path}")
|
||||
|
||||
if app_type == "wsgi":
|
||||
app = self._wrap_wsgi(app)
|
||||
logger.info(f"Wrapped WSGI app: {app_path}")
|
||||
|
||||
self._apps[app_path] = app
|
||||
return app
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import application {app_path}: {e}")
|
||||
return None
|
||||
except AttributeError as e:
|
||||
logger.error(f"Failed to get attribute from {app_path}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load application {app_path}: {e}")
|
||||
return None
|
||||
|
||||
def _wrap_wsgi(self, wsgi_app: Callable) -> ASGIApp:
|
||||
try:
|
||||
from a2wsgi import WSGIMiddleware
|
||||
return cast(ASGIApp, WSGIMiddleware(wsgi_app))
|
||||
except ImportError:
|
||||
logger.warning("a2wsgi not installed, trying asgiref")
|
||||
try:
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
return cast(ASGIApp, WsgiToAsgi(wsgi_app))
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"Neither a2wsgi nor asgiref installed. "
|
||||
"Install with: pip install a2wsgi or pip install asgiref"
|
||||
)
|
||||
raise ImportError(
|
||||
"WSGI adapter not available. Install a2wsgi or asgiref."
|
||||
)
|
||||
|
||||
def get_app(self, app_path: str) -> Optional[ASGIApp]:
|
||||
return self._apps.get(app_path)
|
||||
|
||||
def reload_app(self, app_path: str, **kwargs: Any) -> Optional[ASGIApp]:
|
||||
if app_path in self._apps:
|
||||
del self._apps[app_path]
|
||||
|
||||
if ":" in app_path:
|
||||
module_name, _ = app_path.rsplit(":", 1)
|
||||
else:
|
||||
module_name = app_path
|
||||
|
||||
if module_name in sys.modules:
|
||||
importlib.reload(sys.modules[module_name])
|
||||
|
||||
return self.load_app(app_path, **kwargs)
|
||||
|
||||
|
||||
class MountedApp:
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
app: ASGIApp,
|
||||
name: str = "",
|
||||
strip_path: bool = True,
|
||||
):
|
||||
self.path = path.rstrip("/")
|
||||
self.app = app
|
||||
self.name = name or path
|
||||
self.strip_path = strip_path
|
||||
|
||||
def matches(self, request_path: str) -> bool:
|
||||
if self.path == "":
|
||||
return True
|
||||
return request_path == self.path or request_path.startswith(f"{self.path}/")
|
||||
|
||||
def get_modified_path(self, original_path: str) -> str:
|
||||
if not self.strip_path:
|
||||
return original_path
|
||||
|
||||
if self.path == "":
|
||||
return original_path
|
||||
|
||||
new_path = original_path[len(self.path):]
|
||||
return new_path if new_path else "/"
|
||||
|
||||
|
||||
class ASGIMountManager:
|
||||
def __init__(self):
|
||||
self._mounts: list[MountedApp] = []
|
||||
self._loader = ASGIAppLoader()
|
||||
|
||||
def mount(
|
||||
self,
|
||||
path: str,
|
||||
app: Optional[ASGIApp] = None,
|
||||
app_path: Optional[str] = None,
|
||||
app_type: str = "asgi",
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
name: str = "",
|
||||
strip_path: bool = True,
|
||||
) -> bool:
|
||||
if app is None and app_path is None:
|
||||
logger.error("Either 'app' or 'app_path' must be provided")
|
||||
return False
|
||||
|
||||
if app is None:
|
||||
app = self._loader.load_app(
|
||||
app_path=app_path, # type: ignore
|
||||
app_type=app_type,
|
||||
module_path=module_path,
|
||||
factory=factory,
|
||||
factory_args=factory_args,
|
||||
)
|
||||
if app is None:
|
||||
return False
|
||||
|
||||
mounted = MountedApp(
|
||||
path=path,
|
||||
app=app,
|
||||
name=name or app_path or "unnamed",
|
||||
strip_path=strip_path,
|
||||
)
|
||||
|
||||
self._mounts.append(mounted)
|
||||
self._mounts.sort(key=lambda m: len(m.path), reverse=True)
|
||||
|
||||
logger.info(f"Mounted application '{mounted.name}' at path '{path}'")
|
||||
return True
|
||||
|
||||
def unmount(self, path: str) -> bool:
|
||||
for i, mount in enumerate(self._mounts):
|
||||
if mount.path == path.rstrip("/"):
|
||||
del self._mounts[i]
|
||||
logger.info(f"Unmounted application at path '{path}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_mount(self, request_path: str) -> Optional[MountedApp]:
|
||||
for mount in self._mounts:
|
||||
if mount.matches(request_path):
|
||||
return mount
|
||||
return None
|
||||
|
||||
async def handle_request(
|
||||
self,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
) -> bool:
|
||||
if scope["type"] != "http":
|
||||
return False
|
||||
|
||||
path = scope.get("path", "/")
|
||||
mount = self.get_mount(path)
|
||||
|
||||
if mount is None:
|
||||
return False
|
||||
|
||||
modified_scope = dict(scope)
|
||||
if mount.strip_path:
|
||||
modified_scope["path"] = mount.get_modified_path(path)
|
||||
modified_scope["root_path"] = scope.get("root_path", "") + mount.path
|
||||
|
||||
logger.debug(
|
||||
f"Routing request to mounted app '{mount.name}': "
|
||||
f"{path} -> {modified_scope['path']}"
|
||||
)
|
||||
|
||||
try:
|
||||
await mount.app(modified_scope, receive, send)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error in mounted app '{mount.name}': {e}")
|
||||
raise
|
||||
|
||||
@property
|
||||
def mounts(self) -> list[MountedApp]:
|
||||
return self._mounts.copy()
|
||||
|
||||
def list_mounts(self) -> list[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": mount.path,
|
||||
"name": mount.name,
|
||||
"strip_path": mount.strip_path,
|
||||
}
|
||||
for mount in self._mounts
|
||||
]
|
||||
|
||||
|
||||
def create_fastapi_app(
|
||||
app_path: str,
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
loader = ASGIAppLoader()
|
||||
return loader.load_app(
|
||||
app_path=app_path,
|
||||
app_type="asgi",
|
||||
module_path=module_path,
|
||||
factory=factory,
|
||||
factory_args=factory_args,
|
||||
)
|
||||
|
||||
|
||||
def create_flask_app(
|
||||
app_path: str,
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
loader = ASGIAppLoader()
|
||||
return loader.load_app(
|
||||
app_path=app_path,
|
||||
app_type="wsgi",
|
||||
module_path=module_path,
|
||||
factory=factory,
|
||||
factory_args=factory_args,
|
||||
)
|
||||
|
||||
|
||||
def create_django_app(
|
||||
settings_module: str,
|
||||
module_path: Optional[str] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
import os
|
||||
|
||||
if module_path:
|
||||
module_dir = Path(module_path).resolve()
|
||||
if str(module_dir) not in sys.path:
|
||||
sys.path.insert(0, str(module_dir))
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
|
||||
|
||||
try:
|
||||
from django.core.asgi import get_asgi_application
|
||||
return get_asgi_application()
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to load Django application: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def create_starlette_app(
|
||||
app_path: str,
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
loader = ASGIAppLoader()
|
||||
return loader.load_app(
|
||||
app_path=app_path,
|
||||
app_type="asgi",
|
||||
module_path=module_path,
|
||||
factory=factory,
|
||||
factory_args=factory_args,
|
||||
)
|
||||
Reference in New Issue
Block a user