Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f0eaf3e
feat: Add Flask-to-OpenBB enterprise migration toolkit
BorisQuanLi Nov 7, 2025
90d3398
Merge branch 'develop' into feature/flask-to-openbb-converter
deeleeramone Nov 7, 2025
0e51587
Merge branch 'develop' into feature/flask-to-openbb-converter
deeleeramone Nov 7, 2025
e405dc4
chore: Resolve .gitignore conflict and add common ignores
BorisQuanLi Nov 8, 2025
b1b09ed
refactor: implement Phase 1 Flask integration architecture- Move Flas…
BorisQuanLi Nov 10, 2025
6aa6d07
docs: add Flask direct entry point demos- demo_direct_flask_entry_poi…
BorisQuanLi Nov 10, 2025
3c4f7be
Merge branch 'develop' into feature/flask-to-openbb-converter
deeleeramone Nov 12, 2025
8ee2b33
Implement Phase 1 Flask adapter per code review feedback
BorisQuanLi Nov 12, 2025
475afab
Merge branch 'feature/flask-to-openbb-converter' of https://github.co…
deeleeramone Nov 22, 2025
9a55fa7
Merge branch 'develop' into feature/flask-to-openbb-converter
deeleeramone Nov 22, 2025
a31dc76
Fix Flask integration: Replace missing FlaskToOpenBBAdapter with WSGI…
BorisQuanLi Nov 24, 2025
45898d1
Merge branch 'feature/flask-to-openbb-converter' of https://github.co…
deeleeramone Dec 5, 2025
501572d
fix merge conflict
deeleeramone Dec 5, 2025
fce6a7f
Address PR review feedback: fix Flask imports and remove unused files
BorisQuanLi Dec 7, 2025
97d133c
Merge branch 'develop' into feature/flask-to-openbb-converter
deeleeramone Dec 20, 2025
ab30f3d
Force APIRouter.include_router to propagate Mount paths instead of dr…
deeleeramone Dec 24, 2025
36cfd27
Merge branch 'develop' into feature/flask-to-openbb-converter
deeleeramone Dec 29, 2025
8163d90
Merge branch 'develop' into feature/flask-to-openbb-converter
deeleeramone Dec 30, 2025
1ab2c9d
Merge branch 'develop' into feature/flask-to-openbb-converter
deeleeramone Jan 12, 2026
5ceedad
feat(flask): Add metadata layer for OpenAPI documentation
BorisQuanLi Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions openbb_platform/core/openbb_core/api/app_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion openbb_platform/core/openbb_core/api/router/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)


Expand Down
17 changes: 17 additions & 0 deletions openbb_platform/core/openbb_core/app/extension_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
41 changes: 38 additions & 3 deletions openbb_platform/core/openbb_core/app/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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"):
Expand Down
25 changes: 25 additions & 0 deletions openbb_platform/core/openbb_core/app/utils/flask/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
47 changes: 47 additions & 0 deletions openbb_platform/core/openbb_core/app/utils/flask/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Flask-to-OpenBB conversion logic."""

from typing import Any
from openbb_core.app.router import Router
from openbb_core.app.model.obbject import OBBject
from .introspection import FlaskIntrospector

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, prefix: str = "/flask") -> Router:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't actually want to do this. We have the mounted WSGI application, we don't want to create a new object and replace it, the wrapper already exists as the Middleware.

"""Create OpenBB router from Flask app with metadata layer."""
if not is_flask_available():
raise ImportError("Flask is not available")

router = Router(prefix=prefix)
introspector = FlaskIntrospector(flask_app)
routes = introspector.analyze_routes()

for route_info in routes:
_add_flask_route_to_router(router, route_info, flask_app)

return router

def _add_flask_route_to_router(router: Router, route_info: dict, flask_app: Any) -> None:
"""Add a Flask route to OpenBB router with metadata."""
docstring_info = route_info.get('docstring_info', {})

def flask_wrapper(**kwargs) -> OBBject:
with flask_app.test_client() as client:
response = client.get(route_info['rule'], query_string=kwargs)
return OBBject(results=response.get_json())

flask_wrapper.__name__ = route_info['openbb_command_name']
flask_wrapper.__doc__ = route_info.get('docstring', '')

router.command(
flask_wrapper,
methods=['GET'],
summary=docstring_info.get('summary'),
description=docstring_info.get('description'),
)
Loading
Loading