diff --git a/openbb_platform/core/openbb_core/api/app_loader.py b/openbb_platform/core/openbb_core/api/app_loader.py index 2932242d8814..e0dd3a27f5ce 100644 --- a/openbb_platform/core/openbb_core/api/app_loader.py +++ b/openbb_platform/core/openbb_core/api/app_loader.py @@ -7,6 +7,7 @@ from openbb_core.app.router import RouterLoader from openbb_core.provider.utils.errors import EmptyDataError, UnauthorizedError from pydantic import ValidationError +from starlette.routing import Mount class AppLoader: @@ -15,8 +16,30 @@ class AppLoader: @staticmethod def add_routers(app: FastAPI, routers: list[APIRouter | None], prefix: str): """Add routers.""" + + def _join_paths(p1: str, p2: str) -> str: + if not p1: + return p2 or "/" + if not p2: + return p1 or "/" + joined = p1.rstrip("/") + "/" + p2.lstrip("/") + return joined.rstrip("/") or "/" + for router in routers: if router: + # FastAPI's include_router doesn't propagate Starlette Mount routes. + # If an APIRouter contains mounted sub-apps (e.g. WSGIMiddleware for Flask), + # mount them directly on the FastAPI app with the same prefix. + for route in getattr(router, "routes", []): + if not isinstance(route, Mount): + continue + mount_path = _join_paths(prefix, route.path) + if any( + isinstance(existing, Mount) and existing.path == mount_path + for existing in app.router.routes + ): + continue + app.mount(mount_path, route.app, name=route.name) app.include_router(router=router, prefix=prefix) @staticmethod diff --git a/openbb_platform/core/openbb_core/api/router/commands.py b/openbb_platform/core/openbb_core/api/router/commands.py index 9b09b800c171..07309fadef61 100644 --- a/openbb_platform/core/openbb_core/api/router/commands.py +++ b/openbb_platform/core/openbb_core/api/router/commands.py @@ -23,6 +23,7 @@ from openbb_core.env import Env from openbb_core.provider.utils.helpers import to_snake_case from pydantic import BaseModel +from starlette.routing import Mount from typing_extensions import ParamSpec try: @@ -347,7 +348,18 @@ def add_command_map(command_runner: CommandRunner, api_router: APIRouter) -> Non plugins_router = RouterLoader.from_extensions() for route in plugins_router.api_router.routes: - route.endpoint = build_api_wrapper(command_runner=command_runner, route=route) # type: ignore # noqa + if isinstance(route, APIRoute): + route.endpoint = build_api_wrapper(command_runner=command_runner, route=route) # type: ignore # noqa + continue + # Mounted sub-apps (e.g. WSGIMiddleware for Flask) are Starlette Mount routes. + # APIRouter.include_router will not carry these over, so we mount them manually. + if isinstance(route, Mount): + if any( + isinstance(existing, Mount) and existing.path == route.path + for existing in api_router.routes + ): + continue + api_router.mount(route.path, route.app, name=route.name) api_router.include_router(router=plugins_router.api_router) diff --git a/openbb_platform/core/openbb_core/app/extension_loader.py b/openbb_platform/core/openbb_core/app/extension_loader.py index e8606fda2307..f09e485e8d2b 100644 --- a/openbb_platform/core/openbb_core/app/extension_loader.py +++ b/openbb_platform/core/openbb_core/app/extension_loader.py @@ -175,6 +175,23 @@ def load_core(eps: EntryPoints) -> dict[str, "Router"]: entry = entry.router if isinstance(entry, APIRouter): entries[ep.name] = Router.from_fastapi(entry) + continue + if "flask" in str(type(entry)).lower(): + try: + import flask # noqa: F401 + except ImportError: + continue + from openbb_core.app.utils.flask import FlaskExtensionLoader + + try: + flask_extension = FlaskExtensionLoader.load_flask_extension( + ep.value, ep.name + ) + if flask_extension: + entries[ep.name] = flask_extension + except (ModuleNotFoundError, ImportError): + continue + return entries def load_provider(eps: EntryPoints) -> dict[str, "Provider"]: diff --git a/openbb_platform/core/openbb_core/app/router.py b/openbb_platform/core/openbb_core/app/router.py index 10a3eaaf4470..8be6de2a6c5f 100644 --- a/openbb_platform/core/openbb_core/app/router.py +++ b/openbb_platform/core/openbb_core/app/router.py @@ -15,6 +15,7 @@ ) from fastapi import APIRouter, Depends +from fastapi.routing import APIRoute from openbb_core.app.deprecation import DeprecationSummary, OpenBBDeprecationWarning from openbb_core.app.extension_loader import ExtensionLoader from openbb_core.app.model.abstract.warning import OpenBBWarning @@ -28,6 +29,7 @@ ) from openbb_core.env import Env from pydantic import BaseModel +from starlette.routing import Mount from typing_extensions import ParamSpec P = ParamSpec("P") @@ -182,6 +184,29 @@ def include_router( prefix=prefix, tags=tags, # type: ignore ) + + # FastAPI's APIRouter.include_router only includes APIRoute instances. + # Starlette Mount routes (used by .mount, e.g. for WSGIMiddleware) must + # be manually propagated, otherwise mounted apps silently disappear. + def _join_paths(p1: str, p2: str) -> str: + if not p1: + return p2 or "/" + if not p2: + return p1 or "/" + joined = p1.rstrip("/") + "/" + p2.lstrip("/") + return joined.rstrip("/") or "/" + + for route in router.api_router.routes: + if not isinstance(route, Mount): + continue + mount_path = _join_paths(prefix, route.path) + if any( + isinstance(existing, Mount) and existing.path == mount_path + for existing in self._api_router.routes + ): + continue + self._api_router.mount(mount_path, route.app, name=route.name) + name = prefix if prefix else router.prefix self._routers[name.strip("/")] = router @@ -426,7 +451,11 @@ def get_command_map( ) -> dict[str, Callable]: """Get command map.""" api_router = router.api_router - command_map = {route.path: route.endpoint for route in api_router.routes} # type: ignore + command_map = { + route.path: route.endpoint + for route in api_router.routes + if isinstance(route, APIRoute) + } return command_map @staticmethod @@ -440,6 +469,8 @@ def get_provider_coverage( coverage_map: dict[Any, Any] = {} for route in api_router.routes: + if not isinstance(route, APIRoute): + continue openapi_extra = getattr(route, "openapi_extra", None) if openapi_extra: model = openapi_extra.get("model", None) @@ -471,7 +502,9 @@ def get_command_coverage( coverage_map: dict[Any, Any] = {} for route in api_router.routes: - openapi_extra = getattr(route, "openapi_extra") + if not isinstance(route, APIRoute): + continue + openapi_extra = getattr(route, "openapi_extra", None) if openapi_extra: model = openapi_extra.get("model", None) if model: @@ -493,7 +526,9 @@ def get_commands_model(router: Router, sep: str | None = None) -> dict[str, str] coverage_map: dict[Any, Any] = {} for route in api_router.routes: - openapi_extra = getattr(route, "openapi_extra") + if not isinstance(route, APIRoute): + continue + openapi_extra = getattr(route, "openapi_extra", None) if openapi_extra: model = openapi_extra.get("model", None) if model and hasattr(route, "path"): diff --git a/openbb_platform/core/openbb_core/app/utils.py b/openbb_platform/core/openbb_core/app/utils/__init__.py similarity index 100% rename from openbb_platform/core/openbb_core/app/utils.py rename to openbb_platform/core/openbb_core/app/utils/__init__.py diff --git a/openbb_platform/core/openbb_core/app/utils/flask/__init__.py b/openbb_platform/core/openbb_core/app/utils/flask/__init__.py new file mode 100644 index 000000000000..d4693dbddfe2 --- /dev/null +++ b/openbb_platform/core/openbb_core/app/utils/flask/__init__.py @@ -0,0 +1,25 @@ +"""Flask integration utilities for OpenBB Core. + +This module provides Flask app integration capabilities. +All imports are lazy to avoid ImportError when Flask is not installed. +""" + + +def __getattr__(name: str): + """Lazy import to avoid ImportError when Flask is not installed.""" + if name == "FlaskExtensionLoader": + from .loader import FlaskExtensionLoader + + return FlaskExtensionLoader + if name == "FlaskIntrospector": + from .introspection import FlaskIntrospector + + return FlaskIntrospector + if name == "_check_flask_available": + from .introspection import _check_flask_available + + return _check_flask_available + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = ["FlaskIntrospector", "FlaskExtensionLoader", "_check_flask_available"] diff --git a/openbb_platform/core/openbb_core/app/utils/flask/adapter.py b/openbb_platform/core/openbb_core/app/utils/flask/adapter.py new file mode 100644 index 000000000000..545a11b0d85f --- /dev/null +++ b/openbb_platform/core/openbb_core/app/utils/flask/adapter.py @@ -0,0 +1,23 @@ +"""Flask-to-OpenBB conversion logic.""" + +from typing import Any +from openbb_core.app.router import Router + +def is_flask_available() -> bool: + """Check if Flask is available.""" + try: + import flask + return True + except ImportError: + return False + +def create_flask_router(flask_app: Any) -> Router: + """Create OpenBB router from Flask app - Phase 1 minimal implementation.""" + if not is_flask_available(): + raise ImportError("Flask is not available") + + router = Router(prefix="/flask") + + # Phase 2: Add route introspection and conversion + + return router \ No newline at end of file diff --git a/openbb_platform/core/openbb_core/app/utils/flask/introspection.py b/openbb_platform/core/openbb_core/app/utils/flask/introspection.py new file mode 100644 index 000000000000..1bbb4d6e3cd1 --- /dev/null +++ b/openbb_platform/core/openbb_core/app/utils/flask/introspection.py @@ -0,0 +1,162 @@ +"""Flask route analysis and introspection utilities.""" + +import inspect +import re +import sys +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +if TYPE_CHECKING: + from werkzeug.routing import Rule + + +def _check_flask_available() -> bool: + """Check if Flask is available without importing it.""" + return 'flask' in sys.modules or _can_import_flask() + +def _can_import_flask() -> bool: + """Safely attempt Flask import.""" + try: + import flask + return True + except ImportError: + return False + +class FlaskIntrospector: + """Analyzes Flask applications to extract route information.""" + + def __init__(self, flask_app: Any): + if not _check_flask_available(): + raise ImportError("Flask is not available in the current environment") + self.flask_app = flask_app + self.url_map = flask_app.url_map + + def analyze_routes(self) -> List[Dict[str, Any]]: + """Analyze all routes in the Flask application.""" + routes_info = [] + + for rule in self.url_map.iter_rules(): + if rule.endpoint != 'static': # Skip static file routes + route_info = self._analyze_single_route(rule) + if route_info: + routes_info.append(route_info) + + return routes_info + + def _analyze_single_route(self, rule: "Rule") -> Optional[Dict[str, Any]]: + """Analyze a single Flask route.""" + try: + view_function = self.flask_app.view_functions.get(rule.endpoint) + if not view_function: + return None + + route_info = { + 'rule': rule.rule, + 'endpoint': rule.endpoint, + 'methods': list(rule.methods - {'HEAD', 'OPTIONS'}), + 'function_name': view_function.__name__, + 'function': view_function, + 'url_parameters': list(rule.arguments), + 'query_parameters': self._extract_query_parameters(view_function), + 'docstring': self._extract_docstring(view_function), + 'return_type': self._infer_return_type(view_function), + 'openbb_command_name': self._generate_openbb_command_name(rule.rule, view_function.__name__), + 'pydantic_model_name': self._generate_model_name(view_function.__name__), + } + + return route_info + + except Exception as e: + print(f"Warning: Could not analyze route {rule.rule}: {e}") + return None + + def _extract_query_parameters(self, view_function) -> List[Dict[str, Any]]: + """Extract query parameters from Flask view function.""" + query_params = [] + + try: + source = inspect.getsource(view_function) + param_pattern = r'request\.args\.get\([\'"]([^\'\"]+)[\'"](?:,\s*[\'"]?([^\'\"]*)[\'"]?)?\)' + matches = re.findall(param_pattern, source) + + for match in matches: + param_name = match[0] + default_value = match[1] if len(match) > 1 and match[1] else None + + query_params.append({ + 'name': param_name, + 'default': default_value, + 'type': self._infer_parameter_type(default_value), + 'required': default_value is None + }) + + except Exception as e: + print(f"Warning: Could not extract query parameters from {view_function.__name__}: {e}") + + return query_params + + def _extract_docstring(self, view_function) -> Optional[str]: + """Extract and clean docstring from view function.""" + docstring = inspect.getdoc(view_function) + if docstring: + lines = docstring.strip().split('\n') + cleaned_lines = [line.strip() for line in lines if line.strip()] + return ' '.join(cleaned_lines) + return None + + def _infer_return_type(self, view_function) -> str: + """Infer the return type of a Flask view function.""" + try: + signature = inspect.signature(view_function) + if signature.return_annotation != inspect.Signature.empty: + return str(signature.return_annotation) + + source = inspect.getsource(view_function) + + if 'json.dumps' in source or 'jsonify' in source: + return 'Dict[str, Any]' + elif 'return {' in source: + return 'Dict[str, Any]' + elif 'return [' in source: + return 'List[Dict[str, Any]]' + else: + return 'Any' + + except Exception: + return 'Any' + + def _infer_parameter_type(self, default_value: Optional[str]) -> str: + """Infer parameter type from default value.""" + if default_value is None: + return 'str' + elif default_value.isdigit(): + return 'int' + elif default_value.lower() in ['true', 'false']: + return 'bool' + else: + return 'str' + + def _generate_openbb_command_name(self, rule: str, function_name: str) -> str: + """Generate OpenBB command name from Flask route.""" + clean_rule = rule.lstrip('/') + clean_rule = re.sub(r'<[^>]+>', '', clean_rule) + clean_rule = clean_rule.strip('/') + + command_name = clean_rule.replace('/', '_').replace('-', '_') + + if not command_name or command_name == '_': + command_name = function_name + + command_name = re.sub(r'_+', '_', command_name) + command_name = command_name.strip('_') + + return command_name or function_name + + def _generate_model_name(self, function_name: str) -> str: + """Generate Pydantic model name from function name.""" + words = function_name.split('_') + model_name = ''.join(word.capitalize() for word in words) + + if not model_name.endswith(('Data', 'Response', 'Model')): + model_name += 'Data' + + return model_name \ No newline at end of file diff --git a/openbb_platform/core/openbb_core/app/utils/flask/loader.py b/openbb_platform/core/openbb_core/app/utils/flask/loader.py new file mode 100644 index 000000000000..61a70a49fb3f --- /dev/null +++ b/openbb_platform/core/openbb_core/app/utils/flask/loader.py @@ -0,0 +1,54 @@ +"""Flask extension loading integration.""" + +from typing import Any, Optional +from .introspection import _check_flask_available + + +class FlaskExtensionLoader: + """Integrates Flask app loading with OpenBB's extension system.""" + + @staticmethod + def detect_flask_entry_point(entry_point: str) -> bool: + """Detect if an entry point references a Flask application.""" + try: + module_path, app_name = entry_point.split(':') + module = __import__(module_path, fromlist=[app_name]) + app = getattr(module, app_name) + return FlaskExtensionLoader.validate_flask_app(app) + except Exception: + return False + + @staticmethod + def load_flask_extension(entry_point: str, prefix: str) -> Optional[Any]: + """Load Flask app as OpenBB extension.""" + try: + if not _check_flask_available(): + return None + + module_path, app_name = entry_point.split(':') + module = __import__(module_path, fromlist=[app_name]) + flask_app = getattr(module, app_name) + + if FlaskExtensionLoader.validate_flask_app(flask_app): + from openbb_core.app.router import Router + from fastapi.middleware.wsgi import WSGIMiddleware + + router = Router() + router.api_router.mount("/", WSGIMiddleware(flask_app)) + return router + return None + except Exception as e: + print(f"Error loading Flask extension {entry_point}: {e}") + return None + + @staticmethod + def validate_flask_app(app: Any) -> bool: + """Validate that the loaded object is a proper Flask application.""" + try: + return ( + hasattr(app, 'url_map') and + hasattr(app, 'view_functions') and + hasattr(app, 'name') + ) + except Exception: + return False \ No newline at end of file diff --git a/openbb_platform/core/tests/app/test_extension_loader.py b/openbb_platform/core/tests/app/test_extension_loader.py index 2ddf201c0aa4..fbf5e794d07c 100644 --- a/openbb_platform/core/tests/app/test_extension_loader.py +++ b/openbb_platform/core/tests/app/test_extension_loader.py @@ -216,3 +216,50 @@ def test_core_objects_with_apirouter_instance(mock_entry_points): assert "apirouter_extension" in core_objects assert isinstance(core_objects["apirouter_extension"], Router) mock_entry_points.assert_any_call(group="openbb_core_extension") + + +try: + import flask + + FLASK_AVAILABLE = True +except ImportError: + FLASK_AVAILABLE = False + + +@pytest.mark.skipif(not FLASK_AVAILABLE, reason="Flask is not installed") +@patch("openbb_core.app.extension_loader.entry_points") +def test_core_objects_with_flask_instance(mock_entry_points): + """Test the core_objects property with a Flask instance.""" + mock_flask_app = MagicMock() + mock_flask_app.__class__.__name__ = "Flask" + type(mock_flask_app).__str__ = lambda self: "" + + mock_ep = MagicMock(spec=EntryPoint) + mock_ep.name = "flask_extension" + mock_ep.value = "test_module:app" + mock_ep.load.return_value = mock_flask_app + + mock_entry_points.return_value = [mock_ep] + + el = ExtensionLoader() + _ = el.core_objects + + mock_entry_points.assert_any_call(group="openbb_core_extension") + + +@pytest.mark.skipif(not FLASK_AVAILABLE, reason="Flask is not installed") +def test_flask_extension_loader_import(): + """Test that FlaskExtensionLoader can be imported when Flask is available.""" + from openbb_core.app.utils.flask import FlaskExtensionLoader + + assert FlaskExtensionLoader is not None + assert hasattr(FlaskExtensionLoader, "load_flask_extension") + assert hasattr(FlaskExtensionLoader, "validate_flask_app") + + +@pytest.mark.skipif(FLASK_AVAILABLE, reason="Flask is installed") +def test_extension_loader_without_flask(): + """Test that ExtensionLoader works when Flask is not installed.""" + el = ExtensionLoader() + assert el is not None + assert isinstance(el.core_objects, dict) diff --git a/openbb_platform/core/tests/app/test_platform_router.py b/openbb_platform/core/tests/app/test_platform_router.py index 288e5704561f..98ec189c7a62 100644 --- a/openbb_platform/core/tests/app/test_platform_router.py +++ b/openbb_platform/core/tests/app/test_platform_router.py @@ -18,6 +18,9 @@ SignatureInspector, ) from pydantic import BaseModel, ConfigDict +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Mount class MockBaseModel(BaseModel): @@ -53,6 +56,34 @@ def test_include_router(router): assert router.include_router(some_router) is None +def test_include_router_propagates_mount() -> None: + """Mounted sub-app routes should survive Router.include_router.""" + child = Router() + child.api_router.mount( + "/", + Starlette( + routes=[ + # A simple mounted route to prove the mount exists. + # If Mount isn't propagated, it silently disappears. + # (We don't need to issue HTTP requests here.) + # noqa: E501 + ], + ), + ) + + # Mount an app that has at least one route. + mounted_app = Starlette() + mounted_app.add_route("/health", lambda *_: PlainTextResponse("ok")) + child.api_router.routes.clear() + child.api_router.mount("/", mounted_app) + + parent = Router() + parent.include_router(child, prefix="/flask") + + mount_paths = [r.path for r in parent.api_router.routes if isinstance(r, Mount)] + assert "/flask" in mount_paths + + @pytest.fixture(scope="module") def router_loader(): """Set up router_loader."""