From 49f693602617a3f0f92952a5647eba7a62aa8ca0 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 20 Jan 2025 11:49:19 -0800 Subject: [PATCH] [Feature] Add `no_validate` Flag To `@router.command` (#6988) * add no_validate boolean to router.command kwargs * package builder * update a comment * lint * lint * check @router.command kwargs in StaticCommandRunner by adding APIRoute to ExecutionContext * response_model always None when no_validate is True * add APIRoute forward reference * lint * fix tests and remove unused code in router * fix flakey logging service test * add PATCH to, PUT and POST, in MethodDefinition * update ReferenceGenerator * lint * test_logging_service lint flags --- .../core/openbb_core/api/app_loader.py | 2 + .../openbb_core/api/exception_handlers.py | 34 ++- .../core/openbb_core/api/router/commands.py | 13 +- .../core/openbb_core/app/command_runner.py | 86 +++++--- .../core/openbb_core/app/router.py | 198 +++--------------- .../openbb_core/app/static/package_builder.py | 105 +++++++--- .../tests/app/logs/test_logging_service.py | 3 +- .../core/tests/app/test_command_runner.py | 51 ++++- .../core/tests/app/test_platform_router.py | 101 --------- 9 files changed, 240 insertions(+), 353 deletions(-) diff --git a/openbb_platform/core/openbb_core/api/app_loader.py b/openbb_platform/core/openbb_core/api/app_loader.py index b11b0abefc8..a336e8e9dfc 100644 --- a/openbb_platform/core/openbb_core/api/app_loader.py +++ b/openbb_platform/core/openbb_core/api/app_loader.py @@ -3,6 +3,7 @@ from typing import List, Optional from fastapi import APIRouter, FastAPI +from fastapi.exceptions import ResponseValidationError from openbb_core.api.exception_handlers import ExceptionHandlers from openbb_core.app.model.abstract.error import OpenBBError from openbb_core.app.router import RouterLoader @@ -38,6 +39,7 @@ class AppLoader: """Add exception handlers.""" app.exception_handlers[Exception] = ExceptionHandlers.exception app.exception_handlers[ValidationError] = ExceptionHandlers.validation + app.exception_handlers[ResponseValidationError] = ExceptionHandlers.validation app.exception_handlers[OpenBBError] = ExceptionHandlers.openbb app.exception_handlers[EmptyDataError] = ExceptionHandlers.empty_data app.exception_handlers[UnauthorizedError] = ExceptionHandlers.unauthorized diff --git a/openbb_platform/core/openbb_core/api/exception_handlers.py b/openbb_platform/core/openbb_core/api/exception_handlers.py index 9a96c5eadba..22ff808adb9 100644 --- a/openbb_platform/core/openbb_core/api/exception_handlers.py +++ b/openbb_platform/core/openbb_core/api/exception_handlers.py @@ -4,9 +4,10 @@ import logging from collections.abc import Iterable -from typing import Any +from typing import Any, Union from fastapi import Request +from fastapi.exceptions import ResponseValidationError from fastapi.responses import JSONResponse, Response from openbb_core.app.model.abstract.error import OpenBBError from openbb_core.env import Env @@ -35,7 +36,8 @@ class ExceptionHandlers: @staticmethod async def exception(_: Request, error: Exception) -> JSONResponse: """Exception handler for Base Exception.""" - errors = error.errors(include_url=False) if hasattr(error, "errors") else error + errors = error.errors if hasattr(error, "errors") else error + if errors: if isinstance(errors, ValueError): return await ExceptionHandlers._handle( @@ -55,11 +57,13 @@ class ExceptionHandlers: return await ExceptionHandlers._handle( exception=error, status_code=500, - detail=f"Unexpected Error -> {error.__class__.__name__} -> {str(error.args[0] or error.args)}", + detail=f"Unexpected Error -> {error.__class__.__name__} -> {error}", ) @staticmethod - async def validation(request: Request, error: ValidationError): + async def validation( + request: Request, error: Union[ValidationError, ResponseValidationError] + ): """Exception handler for ValidationError.""" # Some validation is performed at Fetcher level. # So we check if the validation error comes from a QueryParams class. @@ -67,7 +71,27 @@ class ExceptionHandlers: # If yes, we update the error location with query. # If not, we handle it as a base Exception error. query_params = dict(request.query_params) - errors = error.errors(include_url=False) + if isinstance(error, ResponseValidationError): + detail = [ + { + **{k: v for k, v in err.items() if k != "ctx"}, + "loc": ("query",) + err.get("loc", ()), + } + for err in error.errors() + ] + return await ExceptionHandlers._handle( + exception=error, + status_code=422, + detail=detail, + ) + try: + errors = ( + error.errors(include_url=False) + if hasattr(error, "errors") + else error.errors + ) + except Exception: + errors = error.errors if hasattr(error, "errors") else error all_in_query = all( loc in query_params for err in errors for loc in err.get("loc", ()) ) diff --git a/openbb_platform/core/openbb_core/api/router/commands.py b/openbb_platform/core/openbb_core/api/router/commands.py index f5239199c5c..6ac44ffa5eb 100644 --- a/openbb_platform/core/openbb_core/api/router/commands.py +++ b/openbb_platform/core/openbb_core/api/router/commands.py @@ -167,6 +167,10 @@ def validate_output(c_out: OBBject) -> OBBject: elif is_model(getattr(field, "annotation", None)): exclude_fields_from_api(field_name, getattr(value, field_name)) + # Let a non-OBBject object pass through without validation + if not isinstance(c_out, OBBject): + return c_out + for k, v in c_out.model_copy(): exclude_fields_from_api(k, v) @@ -181,12 +185,15 @@ def build_api_wrapper( func: Callable = route.endpoint # type: ignore path: str = route.path # type: ignore + no_validate = route.openapi_extra.get("no_validate") new_signature = build_new_signature(path=path, func=func) new_annotations_map = build_new_annotation_map(sig=new_signature) - func.__signature__ = new_signature # type: ignore func.__annotations__ = new_annotations_map + if no_validate is True: + route.response_model = None + @wraps(wrapped=func) async def wrapper(*args: Tuple[Any], **kwargs: Dict[str, Any]) -> OBBject: user_settings: UserSettings = UserSettings.model_validate( @@ -238,8 +245,10 @@ def build_api_wrapper( kwargs["extra_params"] = extra_params execute = partial(command_runner.run, path, user_settings) - output: OBBject = await execute(*args, **kwargs) + output = await execute(*args, **kwargs) + if no_validate is True: + return output return validate_output(output) return wrapper diff --git a/openbb_platform/core/openbb_core/app/command_runner.py b/openbb_platform/core/openbb_core/app/command_runner.py index c050c6ae340..ebeac7f863d 100644 --- a/openbb_platform/core/openbb_core/app/command_runner.py +++ b/openbb_platform/core/openbb_core/app/command_runner.py @@ -16,11 +16,13 @@ from openbb_core.app.model.abstract.warning import OpenBBWarning, cast_warning from openbb_core.app.model.metadata import Metadata from openbb_core.app.model.obbject import OBBject from openbb_core.app.provider_interface import ExtraParams +from openbb_core.app.static.package_builder import PathHandler from openbb_core.env import Env from openbb_core.provider.utils.helpers import maybe_coroutine, run_async from pydantic import BaseModel, ConfigDict, create_model if TYPE_CHECKING: + from fastapi.routing import APIRoute from openbb_core.app.model.system_settings import SystemSettings from openbb_core.app.model.user_settings import UserSettings from openbb_core.app.router import CommandMap @@ -29,6 +31,9 @@ if TYPE_CHECKING: class ExecutionContext: """Execution context.""" + # For checking if the command specifies no validation in the API Route + _route_map = PathHandler.build_route_map() + def __init__( self, command_map: "CommandMap", @@ -42,6 +47,11 @@ class ExecutionContext: self.system_settings = system_settings self.user_settings = user_settings + @property + def api_route(self) -> "APIRoute": + """API route.""" + return self._route_map[self.route] + class ParametersBuilder: """Build parameters for a function.""" @@ -222,7 +232,12 @@ class StaticCommandRunner: ) -> OBBject: """Run a command and return the output.""" obbject = await maybe_coroutine(func, **kwargs) - obbject.provider = getattr(kwargs.get("provider_choices"), "provider", None) + if isinstance(obbject, OBBject): + obbject.provider = getattr( + kwargs.get("provider_choices"), + "provider", + getattr(obbject, "provider", None), + ) return obbject @classmethod @@ -314,21 +329,49 @@ class StaticCommandRunner: for name, default in model_headers.items() or {} } or None + validate = not execution_context.api_route.openapi_extra.get("no_validate") try: obbject = await cls._command(func, kwargs) + # The output might be from a router command with 'no_validate=True' + # It might be of a different type than OBBject. + # In this case, we avoid accessing those attributes. + if isinstance(obbject, OBBject) or validate: + if validate and not isinstance(obbject, OBBject): + raise OpenBBError( + TypeError( + f"Expected OBBject instance at function output, got {type(obbject)} instead." + ) + ) + # This section prepares the obbject to pass to the charting service. + obbject._route = route # pylint: disable=protected-access + std_params = cls._extract_params(kwargs, "standard_params") or ( + kwargs if "data" in kwargs else {} + ) + extra_params = cls._extract_params(kwargs, "extra_params") + obbject._standard_params = ( # pylint: disable=protected-access + std_params + ) + obbject._extra_params = ( # pylint: disable=protected-access + extra_params + ) + if chart and obbject.results: + cls._chart(obbject, **kwargs) - # This section prepares the obbject to pass to the charting service. - obbject._route = route # pylint: disable=protected-access - std_params = cls._extract_params(kwargs, "standard_params") or ( - kwargs if "data" in kwargs else {} - ) - extra_params = cls._extract_params(kwargs, "extra_params") - obbject._standard_params = ( # pylint: disable=protected-access - std_params - ) - obbject._extra_params = extra_params # pylint: disable=protected-access - if chart and obbject.results: - cls._chart(obbject, **kwargs) + if warning_list: + if isinstance(obbject, OBBject): + obbject.warnings = [] + for w in warning_list: + if isinstance(obbject, OBBject): + obbject.warnings.append(cast_warning(w)) + if user_settings.preferences.show_warnings: + showwarning( + message=w.message, + category=w.category, + filename=w.filename, + lineno=w.lineno, + file=w.file, + line=w.line, + ) finally: ls = LoggingService(system_settings, user_settings) ls.log( @@ -341,19 +384,6 @@ class StaticCommandRunner: custom_headers=custom_headers, ) - if warning_list: - obbject.warnings = [] - for w in warning_list: - obbject.warnings.append(cast_warning(w)) - if user_settings.preferences.show_warnings: - showwarning( - message=w.message, - category=w.category, - filename=w.filename, - lineno=w.lineno, - file=w.file, - line=w.line, - ) return obbject # pylint: disable=W0718 @@ -385,7 +415,9 @@ class StaticCommandRunner: duration = perf_counter_ns() - start_ns - if execution_context.user_settings.preferences.metadata: + if execution_context.user_settings.preferences.metadata and isinstance( + obbject, OBBject + ): try: obbject.extra["metadata"] = Metadata( arguments=kwargs, diff --git a/openbb_platform/core/openbb_core/app/router.py b/openbb_platform/core/openbb_core/app/router.py index 72c2e375175..1eb97f8fd4a 100644 --- a/openbb_platform/core/openbb_core/app/router.py +++ b/openbb_platform/core/openbb_core/app/router.py @@ -2,14 +2,13 @@ import traceback import warnings -from functools import lru_cache, partial -from inspect import Parameter, Signature, isclass, iscoroutinefunction, signature +from functools import lru_cache +from inspect import isclass from typing import ( Any, Callable, Dict, List, - Mapping, Optional, Type, get_args, @@ -32,8 +31,7 @@ from openbb_core.app.provider_interface import ( ) from openbb_core.env import Env from pydantic import BaseModel -from pydantic.v1.validators import find_validators -from typing_extensions import Annotated, ParamSpec, _AnnotatedAlias +from typing_extensions import Annotated, ParamSpec P = ParamSpec("P") @@ -45,158 +43,6 @@ class OpenBBErrorResponse(BaseModel): error_kind: str -class CommandValidator: - """Validate Command.""" - - @staticmethod - def is_standard_pydantic_type(value_type: Type) -> bool: - """Check whether or not a parameter type is a valid Pydantic Standard Type.""" - try: - func = next( - find_validators(value_type, config=dict(arbitrary_types_allowed=True)) # type: ignore - ) - valid_type = func.__name__ != "arbitrary_type_validator" - except Exception: - valid_type = False - - return valid_type - - @staticmethod - def is_valid_pydantic_model_type(model_type: Type) -> bool: - """Check whether or not a parameter type is a valid Pydantic Model Type.""" - if not isclass(model_type): - return False - - if issubclass(model_type, BaseModel): - try: - model_type.model_json_schema() - return True - except ValueError: - return False - return False - - @classmethod - def is_serializable_value_type(cls, value_type: Type) -> bool: - """Check whether or not a parameter type is a valid serializable type.""" - return cls.is_standard_pydantic_type( - value_type=value_type - ) or cls.is_valid_pydantic_model_type(model_type=value_type) - - @staticmethod - def is_annotated_dc(annotation) -> bool: - """Check whether or not a parameter type is an annotated dataclass.""" - return isinstance(annotation, _AnnotatedAlias) and hasattr( - annotation.__args__[0], "__dataclass_fields__" - ) - - @staticmethod - def check_reserved_param( - name: str, - expected_annot: Any, - parameter_map: Mapping[str, Parameter], - func: Callable, - sig: Signature, - ): - """Check whether or not a parameter is reserved.""" - if name in parameter_map: - annotation = getattr(parameter_map[name], "annotation", None) - if annotation is not None and CommandValidator.is_annotated_dc(annotation): - annotation = annotation.__args__[0].__bases__[0] - if not annotation == expected_annot: - raise TypeError( - f"The parameter `{name}` must be a {expected_annot}.\n" - f"module = {func.__module__}\n" - f"function = {func.__name__}\n" - f"signature = {sig}\n" - ) - - @classmethod - def check_parameters(cls, func: Callable): - """Check whether or not a parameter is a valid.""" - # pylint: disable=import-outside-toplevel - from openbb_core.app.model.command_context import CommandContext - - sig = signature(func) - parameter_map = sig.parameters - - check_reserved = partial( - cls.check_reserved_param, parameter_map=parameter_map, func=func, sig=sig - ) - check_reserved("cc", CommandContext) - check_reserved("provider_choices", ProviderChoices) - check_reserved("standard_params", StandardParams) - check_reserved("extra_params", ExtraParams) - - for parameter in parameter_map.values(): - if not cls.is_serializable_value_type(value_type=parameter.annotation): - raise TypeError( - "Invalid parameter type, please provide a serializable type like:" - "BaseModel, Pydantic Standard Type or CommandContext.\n" - f"module = {func.__module__}\n" - f"function = {func.__name__}\n" - f"signature = {sig}\n" - f"parameter = {parameter}\n" - ) - - @classmethod - def check_return(cls, func: Callable): - """Check whether or not a return type is a valid.""" - # pylint: disable=import-outside-toplevel - - sig = signature(func) - return_type = sig.return_annotation - - valid_return_type = False - - if isclass(return_type) and issubclass(return_type, OBBject): - results_type = return_type.__pydantic_generic_metadata__.get("args", [])[ - 0 - ] # type: ignore - if not isinstance(results_type, type(None)): - generic_type_list = get_args(results_type) - if len(generic_type_list) >= 1: - valid_return_type = cls.is_serializable_value_type( - value_type=generic_type_list[len(generic_type_list) - 1] - ) - else: - valid_return_type = cls.is_serializable_value_type( - value_type=results_type - ) - - if not valid_return_type: - raise TypeError( - "\nInvalid function: " - f" {func.__module__}.{func.__name__}\n" - "Invalid return type in signature:" - f" {func.__name__}(...) -> {sig.return_annotation}:\n" - "Allowed return type:" - f" {func.__name__}(...) -> OBBject[T] :\n" - "If you need T = None, use an empty model instead.\n" - ) - - @classmethod - def check(cls, func: Callable, model: str = ""): - """Check whether or not a function is valid.""" - if model and not iscoroutinefunction(func): - raise TypeError( - f"Invalid function: {func.__module__}.{func.__name__}\n" - "Model is specified but function is not async.\n" - "\n\n" - '\033[92m@router.command(model="WorldNews")\n' - "async def world(\n" - " cc: CommandContext,\n" - " provider_choices: ProviderChoices,\n" - " standard_params: StandardParams,\n" - " extra_params: ExtraParams,\n" - ") -> OBBject:\n" - ' """World News. Global news data."""\n' - " return await OBBject.from_query(Query(**locals()))\033[0m" - ) - - cls.check_return(func=func) - cls.check_parameters(func=func) - - class Router: """OpenBB Router Class.""" @@ -253,7 +99,9 @@ class Router: api_router = self._api_router model = kwargs.pop("model", "") - + no_validate = kwargs.pop("no_validate", None) + if no_validate is True: + func.__annotations__["return"] = None if func := SignatureInspector.complete(func, model): kwargs["response_model_exclude_unset"] = True @@ -263,15 +111,20 @@ class Router: examples=kwargs.pop("examples", []), providers=ProviderInterface().available_providers, ) + kwargs["openapi_extra"]["no_validate"] = no_validate kwargs["operation_id"] = kwargs.get( "operation_id", SignatureInspector.get_operation_id(func) ) kwargs["path"] = kwargs.get("path", f"/{func.__name__}") kwargs["endpoint"] = func kwargs["methods"] = kwargs.get("methods", ["GET"]) - kwargs["response_model"] = kwargs.get( - "response_model", - func.__annotations__["return"], # type: ignore + kwargs["response_model"] = ( + kwargs.get( + "response_model", + func.__annotations__["return"], # type: ignore + ) + if not no_validate + else func.__annotations__["return"] ) kwargs["response_model_by_alias"] = kwargs.get( "response_model_by_alias", False @@ -431,19 +284,20 @@ class SignatureInspector: return_type = func.__annotations__["return"] is_list = False - results_type = get_type_hints(return_type)["results"] - results_type_args = get_args(results_type) - if not isinstance(results_type, type(None)): - results_type = results_type_args[0] + if return_type == OBBject: + results_type = get_type_hints(return_type)["results"] + results_type_args = get_args(results_type) + if not isinstance(results_type, type(None)): + results_type = results_type_args[0] - is_list = isinstance(get_origin(results_type), list) - inner_type = ( - results_type_args[0] if is_list and results_type_args else results_type - ) - inner_type_name = getattr(inner_type, "__name__", inner_type) + is_list = isinstance(get_origin(results_type), list) + inner_type = ( + results_type_args[0] if is_list and results_type_args else results_type + ) + inner_type_name = getattr(inner_type, "__name__", inner_type) - func.__annotations__["return"].__doc__ = "OBBject" - func.__annotations__["return"].__name__ = f"OBBject[{inner_type_name}]" + func.__annotations__["return"].__doc__ = "OBBject" + func.__annotations__["return"].__name__ = f"OBBject[{inner_type_name}]" return func diff --git a/openbb_platform/core/openbb_core/app/static/package_builder.py b/openbb_platform/core/openbb_core/app/static/package_builder.py index 9dee1cf5752..9eae47982da 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder.py @@ -301,7 +301,13 @@ class ImportDefinition: """Filter the hint type list.""" new_hint_type_list = [] for hint_type in hint_type_list: - if hint_type != _empty and hint_type.__module__ != "builtins": + if hint_type != _empty and ( + ( + hasattr(hint_type, "__module__") + and hint_type.__module__ != "builtins" + ) + or (isinstance(hint_type, str)) + ): new_hint_type_list.append(hint_type) new_hint_type_list = list(set(new_hint_type_list)) @@ -309,21 +315,32 @@ class ImportDefinition: return new_hint_type_list @classmethod - def get_function_hint_type_list(cls, func: Callable) -> List[Type]: + def get_function_hint_type_list(cls, route) -> List[Type]: """Get the hint type list from the function.""" - sig = signature(func) - parameter_map = sig.parameters - return_type = sig.return_annotation - hint_type_list = [] + no_validate = route.openapi_extra.get("no_validate") + + func = route.endpoint + sig = signature(func) + if no_validate is True: + route.response_model = None + + parameter_map = sig.parameters + return_type = sig.return_annotation if not no_validate else route.response_model + + hint_type_list: list = [] for parameter in parameter_map.values(): hint_type_list.append(parameter.annotation) if return_type: - if not issubclass(return_type, OBBject): + if not no_validate and not issubclass(return_type, OBBject): raise ValueError("Return type must be an OBBject.") - hint_type = get_args(get_type_hints(return_type)["results"])[0] + hint_type = ( + get_args(get_type_hints(return_type)["results"])[0] + if "OBBject" in return_type.__class__.__name__ + else return_type + ) hint_type_list.append(hint_type) hint_type_list = cls.filter_hint_type_list(hint_type_list) @@ -344,7 +361,7 @@ class ImportDefinition: if route: if route.deprecated: hint_type_list.append(type(route.summary.metadata)) - function_hint_type_list = cls.get_function_hint_type_list(func=route.endpoint) # type: ignore + function_hint_type_list = cls.get_function_hint_type_list(route=route) # type: ignore hint_type_list.extend(function_hint_type_list) hint_type_list = list(set(hint_type_list)) @@ -380,7 +397,10 @@ class ImportDefinition: code += "\nfrom openbb_core.app.static.utils.filters import filter_inputs\n" code += "\nfrom openbb_core.app.deprecation import OpenBBDeprecationWarning\n" code += "\nfrom openbb_core.app.model.field import OpenBBField" - module_list = [hint_type.__module__ for hint_type in hint_type_list] + module_list = [ + hint_type.__module__ if hasattr(hint_type, "__module__") else hint_type + for hint_type in hint_type_list + ] module_list = list(set(module_list)) module_list.sort() @@ -536,7 +556,7 @@ class MethodDefinition: def is_data_processing_function(path: str) -> bool: """Check if the function is a data processing function.""" methods = PathHandler.build_route_map()[path].methods # type: ignore - return "POST" in methods + return "POST" in methods or "PUT" in methods or "PATCH" in methods @staticmethod def is_deprecated_function(path: str) -> bool: @@ -714,12 +734,14 @@ class MethodDefinition: """Build the function returns.""" if return_type == _empty: func_returns = "None" - elif return_type.__module__ == "builtins": - func_returns = return_type.__name__ - else: + elif isinstance(return_type, str): + func_returns = f"ForwardRef('{return_type}')" + elif isclass(return_type) and issubclass(return_type, OBBject): func_returns = "OBBject" + else: + func_returns = return_type.__name__ if return_type else Any # type: ignore - return func_returns + return func_returns # type: ignore @staticmethod def build_command_method_signature( @@ -1361,6 +1383,7 @@ class ReferenceGenerator: # pylint: disable=protected-access pi = DocstringGenerator.provider_interface + route_map = PathHandler.build_route_map() @classmethod def _get_endpoint_examples( @@ -1668,7 +1691,11 @@ class ReferenceGenerator: content_inside_brackets = re.search( r"OBBject\[\s*((?:[^\[\]]|\[[^\[\]]*\])*)\s*\]", return_type ) - return_type = content_inside_brackets.group(1) # type: ignore + return_type = ( # type: ignore + content_inside_brackets.group(1) + if content_inside_brackets is not None + else return_type + ) returns_list = [ { @@ -1723,16 +1750,14 @@ class ReferenceGenerator: route_func, examples, # type: ignore ) + validate_output = not cls.route_map[path].openapi_extra.get("no_validate") + model_map = cls.pi.map.get(standard_model, {}) + # Add data for the endpoints having a standard model - if route_method == {"GET"}: + if route_method == {"GET"} and model_map: reference[path]["description"] = getattr( route, "description", "No description available." ) - - # TODO: The reference is not getting populated when a command does not use a standard model - # Access model map from the ProviderInterface - model_map = cls.pi.map.get(standard_model, {}) - for provider in model_map: if provider == "openbb": # openbb provider is always present hence its the standard field @@ -1789,28 +1814,42 @@ class ReferenceGenerator: # Add endpoint returns data # Currently only OBBject object is returned - providers = provider_parameter_fields["type"] - reference[path]["returns"]["OBBject"] = cls._get_obbject_returns_fields( - standard_model, providers - ) + if validate_output is False: + reference[path]["returns"]["Any"] = { + "description": "Unvalidated results object.", + } + else: + providers = provider_parameter_fields["type"] + reference[path]["returns"]["OBBject"] = ( + cls._get_obbject_returns_fields(standard_model, providers) + ) # Add data for the endpoints without a standard model (data processing endpoints) - elif route_method == {"POST"}: - # POST method router `description` attribute is unreliable as it may or + elif ( + route_method == {"POST"} + or "PUT" in str(route_method) + or "PATCH" in str(route_method) + ) or (route_method == {"GET"} and not model_map): + # Non-model method's router `description` attribute is unreliable as it may or # may not contain the "Parameters" and "Returns" sections. Hence, the # endpoint function docstring is used instead. docstring = getattr(route_func, "__doc__", "") description = docstring.split("Parameters")[0].strip() # Remove extra spaces in between the string reference[path]["description"] = re.sub(" +", " ", description) - # Add endpoint parameters fields for POST methods + # Add endpoint parameters fields for non-model methods reference[path]["parameters"]["standard"] = ( cls._get_post_method_parameters_info(docstring) ) # Add endpoint returns data - # Currently only OBBject object is returned - reference[path]["returns"]["OBBject"] = ( - cls._get_post_method_returns_info(docstring) - ) + # If the endpoint is not validated, the return type is set to Any + if validate_output is False: + reference[path]["returns"]["Any"] = { + "description": "Unvalidated results object.", + } + else: + reference[path]["returns"]["OBBject"] = ( + cls._get_post_method_returns_info(docstring) + ) return reference diff --git a/openbb_platform/core/tests/app/logs/test_logging_service.py b/openbb_platform/core/tests/app/logs/test_logging_service.py index 04ec8d097ac..369bc3a5319 100644 --- a/openbb_platform/core/tests/app/logs/test_logging_service.py +++ b/openbb_platform/core/tests/app/logs/test_logging_service.py @@ -84,11 +84,10 @@ def test_correctly_initialized(): "openbb_core.app.logs.logging_service.LoggingService._log_startup", mock_log_startup, ): - LoggingService( + _ = LoggingService( system_settings=mock_system_settings, user_settings=mock_user_settings, ) - mock_setup_handlers.assert_called_once() mock_log_startup.assert_called_once() diff --git a/openbb_platform/core/tests/app/test_command_runner.py b/openbb_platform/core/tests/app/test_command_runner.py index 092a9d549df..51fd5f23342 100644 --- a/openbb_platform/core/tests/app/test_command_runner.py +++ b/openbb_platform/core/tests/app/test_command_runner.py @@ -26,13 +26,40 @@ from pydantic import BaseModel, ConfigDict # pylint: disable=W0613, W0621, W0102, W0212 +class MockAPIRoute: + """MockAPIRoute""" + + def __init__(self, route): + """Initialize the mock API route.""" + self.route = route + self.openapi_extra = {"no_validate": True} + + +class MockExecutionContext: + """MockExecutionContext""" + + _route_map = {"mock/route": "mock_func"} + + def __init__(self, cmd_map, route, sys, user): + """Initialize the mock execution context.""" + self.command_map = cmd_map + self.route = route + self.system_settings = sys + self.user_settings = user + + @property + def api_route(self) -> str: + """Mock API route.""" + return MockAPIRoute(self.route) + + @pytest.fixture() def execution_context(): """Set up execution context.""" sys = SystemSettings() user = UserSettings() cmd_map = CommandMap() - return ExecutionContext(cmd_map, "mock/route", sys, user) + return MockExecutionContext(cmd_map, "mock/route", sys, user) @pytest.fixture() @@ -304,6 +331,7 @@ async def test_static_command_runner_run( self.results = results self.extra = {} self.extra["metadata"] = {"test": "test"} + self.provider = None mock_get_command.return_value = other_mock_func mock_execute_func.return_value = MockOBBject(results=[1, 2, 3, 4]) @@ -330,12 +358,7 @@ async def test_static_command_runner_execute_func( ): """Test execute_func.""" - class MockOBBject: - """Mock OBBject""" - - def __init__(self, results): - self.results = results - self.extra = {} + static_command_runner = StaticCommandRunner() mock_parameters_builder_build.return_value = { "a": 1, @@ -346,10 +369,14 @@ async def test_static_command_runner_execute_func( "chart": True, } mock_logging_service.log.return_value = None - mock_command.return_value = MockOBBject(results=[1, 2, 3, 4]) + mock_command.return_value = OBBject( + results=[1, 2, 3, 4], + provider="mock_provider", + accessors={"charting": Mock()}, + ) mock_chart.return_value = None - result = await StaticCommandRunner._execute_func( + result = await static_command_runner._execute_func( "mock/route", (1, 2, 3, 4), execution_context, mock_func, {"chart": True} ) @@ -369,6 +396,7 @@ def test_static_command_runner_chart(): {"date": "1991", "value": 200}, {"date": "1992", "value": 300}, ], + provider="mock_provider", accessors={"charting": Mock()}, ) mock_obbject.charting.show = Mock() @@ -385,9 +413,10 @@ async def test_static_command_runner_command(): class MockOBBject: """Mock OBBject""" - def __init__(self, results): + def __init__(self, results, **kwargs): self.results = results self.extra = {} + self.provider = kwargs.get("provider_choices").provider class MockProviderChoices: """Mock ProviderChoices""" @@ -396,7 +425,7 @@ async def test_static_command_runner_command(): self.provider = provider def other_mock_func(**kwargs): - return MockOBBject(results=[1, 2, 3, 4]) + return MockOBBject([1, 2, 3, 4], **kwargs) mock_provider_choices = MockProviderChoices(provider="mock_provider") diff --git a/openbb_platform/core/tests/app/test_platform_router.py b/openbb_platform/core/tests/app/test_platform_router.py index e42e0f49e3e..0a9adbca916 100644 --- a/openbb_platform/core/tests/app/test_platform_router.py +++ b/openbb_platform/core/tests/app/test_platform_router.py @@ -16,7 +16,6 @@ from openbb_core.app.provider_interface import ( ) from openbb_core.app.router import ( CommandMap, - CommandValidator, Router, RouterLoader, SignatureInspector, @@ -30,106 +29,6 @@ class MockBaseModel(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) -@pytest.fixture(scope="module") -def command_validator(): - """Set up command_validator.""" - return CommandValidator() - - -def test_command_validator_init(command_validator): - """Test init.""" - assert command_validator - - -@pytest.mark.parametrize( - "type_, expected", - [ - (str, True), - (int, True), - (float, True), - (bool, True), - (list, True), - (MockBaseModel, False), - ], -) -def test_is_standard_pydantic_type(command_validator, type_, expected): - """Test is_standard_pydantic_type.""" - # assert command_validator.is_standard_pydantic_type(str) - assert command_validator.is_standard_pydantic_type(type_) == expected - - -def test_is_valid_pydantic_model_type(command_validator): - """Test is_valid_pydantic_model_type.""" - assert command_validator.is_valid_pydantic_model_type(MockBaseModel) - assert not command_validator.is_valid_pydantic_model_type(str) - - -@pytest.mark.parametrize( - "type_, expected", - [ - (str, True), - (int, True), - (float, True), - (bool, True), - (list, True), - (MockBaseModel, True), - ], -) -def test_is_serializable_value_type(command_validator, type_, expected): - """Test is_serializable_value_type.""" - assert command_validator.is_serializable_value_type(type_) == expected - - -def test_is_annotated_dc(command_validator): - """Test is_annotated_dc.""" - assert not command_validator.is_annotated_dc(str) - - -def test_check_reserved_param(command_validator): - """Test check_reserved_param.""" - assert command_validator.check_reserved_param("name", str, {}, str, str) is None - assert not command_validator.check_reserved_param("name", str, {}, str, int) - - -def test_check_parameters(command_validator): - """Test check_parameters.""" - - async def func(): - pass - - assert command_validator.check_parameters(func) is None - with pytest.raises(TypeError): - command_validator.check_parameters(MockBaseModel) - - -def test_check_return_error(command_validator): - """Test check_return fail.""" - with pytest.raises(TypeError): - - async def func(): - pass - - command_validator.check_return(func) - - -def test_check_return(command_validator): - """Test check_return.""" - - async def valid_function() -> OBBject[Optional[List[int]]]: - return OBBject(results=[1, 2, 3]) - - assert command_validator.check_return(valid_function) is None - - -def test_check(command_validator): - """Test check.""" - - async def valid_function() -> OBBject[Optional[List[int]]]: - return OBBject(results=[1, 2, 3]) - - assert command_validator.check(valid_function) is None - - @pytest.fixture(scope="module") def router(): """Set up router."""