Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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"]
23 changes: 23 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,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
162 changes: 162 additions & 0 deletions openbb_platform/core/openbb_core/app/utils/flask/introspection.py
Original file line number Diff line number Diff line change
@@ -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
Loading