diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 88debe32dd2..4e6014d469e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,11 +13,11 @@ name: "CodeQL Advanced" on: push: - branches: [ "develop", "PR-review", "gh-pages", "main" ] + branches: [ "develop", "PR-review", "gh-pages", "main", "v5" ] paths-ignore: - 'cookiecutter/**' pull_request: - branches: [ "develop", "PR-review", "gh-pages", "main" ] + branches: [ "develop", "PR-review", "gh-pages", "main", "v5" ] paths-ignore: - 'cookiecutter/**' schedule: diff --git a/.github/workflows/test-openbb-core.yml b/.github/workflows/test-openbb-core.yml index 0dc0be2bcbd..2e2c72d225b 100644 --- a/.github/workflows/test-openbb-core.yml +++ b/.github/workflows/test-openbb-core.yml @@ -13,9 +13,6 @@ on: branches: - develop - v5 - - 'feature/**' - - 'hotfix/**' - - 'release/**' paths: - 'openbb_platform/core/**' - 'openbb_platform/extensions/devtools/**' @@ -37,6 +34,9 @@ jobs: test: name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }}) runs-on: ${{ matrix.os }} + env: + OPENBB_ALLOW_ON_COMMAND_OUTPUT: "True" + OPENBB_ALLOW_MUTABLE_EXTENSIONS: "True" permissions: contents: read strategy: @@ -72,16 +72,80 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[pandas]" - pip install pytest pytest-cov pytest-asyncio httpx + pip install pytest pytest-cov pytest-asyncio httpx coverage[toml] - name: Run unit tests run: | - pytest tests --cov=openbb_core --cov-report=term-missing --cov-report=xml + pytest tests \ + --cov=openbb_core \ + --cov-config=.coveragerc \ + --cov-report=term-missing \ + --cov-report=xml:coverage-unit.xml - - name: Upload coverage artifact + - name: Upload unit-test coverage artifact if: always() uses: actions/upload-artifact@v4 with: - name: openbb-core-coverage-${{ matrix.os }}-py${{ matrix.python_version }} - path: openbb_platform/core/coverage.xml + name: openbb-core-coverage-unit-${{ matrix.os }}-py${{ matrix.python_version }} + path: openbb_platform/core/coverage-unit.xml + if-no-files-found: ignore + + integration: + name: Integration (ubuntu / py3.13) + runs-on: ubuntu-latest + needs: test + env: + OPENBB_ALLOW_ON_COMMAND_OUTPUT: "True" + OPENBB_ALLOW_MUTABLE_EXTENSIONS: "True" + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install Python 3.13 + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: "pip" + + - name: Install openbb-core (with pandas extra) and test deps + run: | + python -m pip install --upgrade pip + pip install -e ".[pandas]" + pip install pytest pytest-cov pytest-asyncio httpx coverage[toml] + + - name: Enable subprocess coverage (sitecustomize shim) + run: | + # `coverage.process_startup()` only fires in child interpreters when + # both COVERAGE_PROCESS_START is set AND a sitecustomize.py on + # sys.path calls it. Drop the shim into site-packages so every + # `python` subprocess spawned by the integration suite (including + # the one that runs `openbb-build`) is instrumented. + python -c "import sysconfig, pathlib; \ + p = pathlib.Path(sysconfig.get_paths()['purelib']) / 'sitecustomize.py'; \ + p.write_text('import coverage\ncoverage.process_startup()\n'); \ + print('wrote', p)" + + - name: Run integration tests with subprocess coverage + env: + COVERAGE_PROCESS_START: ${{ github.workspace }}/openbb_platform/core/.coveragerc + COVERAGE_FILE: .coverage.integration + run: | + # Run pytest directly. Child interpreters are instrumented via + # COVERAGE_PROCESS_START + sitecustomize and write parallel + # `.coverage.*` files in CWD. + pytest integration -m integration -v + coverage combine + coverage report --rcfile=.coveragerc + coverage xml --rcfile=.coveragerc -o coverage-integration.xml + + - name: Upload integration coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: openbb-core-coverage-integration + path: openbb_platform/core/coverage-integration.xml if-no-files-found: ignore diff --git a/openbb_platform/core/.coveragerc b/openbb_platform/core/.coveragerc new file mode 100644 index 00000000000..4712b651c1c --- /dev/null +++ b/openbb_platform/core/.coveragerc @@ -0,0 +1,26 @@ +[run] +branch = False +parallel = True +source = openbb_core +data_file = .coverage +omit = + openbb_core/build.py + openbb_core/provider/utils/update_country_data.py + openbb_core/provider/utils/update_exchange_data.py + tests/* + integration/* + +[paths] +source = + openbb_core + */site-packages/openbb_core + */openbb_platform/core/openbb_core + +[report] +show_missing = True +skip_covered = False +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + if __name__ == .__main__.: + raise NotImplementedError diff --git a/openbb_platform/core/integration/conftest.py b/openbb_platform/core/integration/conftest.py index f762085b26e..df03b23ca28 100644 --- a/openbb_platform/core/integration/conftest.py +++ b/openbb_platform/core/integration/conftest.py @@ -211,6 +211,22 @@ def _pip(*args: str) -> None: ) +def _subprocess_env(extra: dict[str, str] | None = None) -> dict[str, str]: + """Build a subprocess env that opts into coverage.py auto-startup if a config exists. + + Strips ``COV_CORE_*`` so the child does not also try to bootstrap via + ``pytest_cov.embed.init()`` (which would race with ``coverage.process_startup`` + and write into the parent's locked data file). + """ + env = {k: v for k, v in os.environ.items() if not k.startswith("COV_CORE_")} + rcfile = CORE_DIR / ".coveragerc" + if rcfile.exists(): + env["COVERAGE_PROCESS_START"] = str(rcfile) + if extra: + env.update(extra) + return env + + def _run_openbb_build() -> None: """Invoke ``openbb-build`` in a fresh subprocess. @@ -218,8 +234,7 @@ def _run_openbb_build() -> None: cache entry points at module load time; the parent process cannot discover the freshly-installed distribution without a restart. """ - env = os.environ.copy() - env["OPENBB_AUTO_BUILD"] = "true" + env = _subprocess_env({"OPENBB_AUTO_BUILD": "true"}) subprocess.run( [sys.executable, "-c", "import openbb"], check=True, @@ -308,7 +323,7 @@ def run_in_obb(fake_extension_installed): # noqa: ARG001 check=False, capture_output=True, text=True, - env={**os.environ, "OPENBB_AUTO_BUILD": "false"}, + env=_subprocess_env({"OPENBB_AUTO_BUILD": "false"}), ) if proc.returncode != 0: raise AssertionError( diff --git a/openbb_platform/core/integration/test_built_package.py b/openbb_platform/core/integration/test_built_package.py index 1267d01bb0a..980a2e3295f 100644 --- a/openbb_platform/core/integration/test_built_package.py +++ b/openbb_platform/core/integration/test_built_package.py @@ -79,11 +79,6 @@ def test_obb_coverage_reports_fake_integration_provider(run_in_obb): ) -# --------------------------------------------------------------------------- -# OBBject built-in conversion methods (require pandas) -# --------------------------------------------------------------------------- - - pandas_required = pytest.mark.requires_pandas @@ -143,11 +138,6 @@ def test_obbject_to_llm_emits_json_string(run_in_obb): assert result["parsed"][0]["symbol"] == "FAKE" -# --------------------------------------------------------------------------- -# OBBject extension (accessor) — openbb_obbject_extension entry point -# --------------------------------------------------------------------------- - - def test_fake_accessor_is_registered_on_obbject_class(run_in_obb): """The ``openbb_obbject_extension`` entry point registers the accessor on ``OBBject``. diff --git a/openbb_platform/core/openbb/__init__.py b/openbb_platform/core/openbb/__init__.py index 9c3e52e9491..ab194efabc9 100644 --- a/openbb_platform/core/openbb/__init__.py +++ b/openbb_platform/core/openbb/__init__.py @@ -3,6 +3,7 @@ # flake8: noqa from pathlib import Path +from importlib import import_module from typing import List, Optional, Union from openbb_core.app.static.app_factory import ( @@ -40,10 +41,11 @@ _PackageBuilder(_this_dir).auto_build() _ReferenceLoader(_this_dir) try: - from openbb.package.__extensions__ import Extensions as _Extensions # type: ignore + _extensions_module = import_module("openbb.package.__extensions__") + _Extensions = _extensions_module.Extensions - obb: Union[_BaseApp, _Extensions] = _create_app(_Extensions) + obb = _create_app(_Extensions) sdk = obb -except (ImportError, ModuleNotFoundError): +except (AttributeError, ImportError, ModuleNotFoundError): print("Failed to import extensions. Are any installed?") obb = sdk = _create_app() diff --git a/openbb_platform/core/openbb_core/api/router/commands.py b/openbb_platform/core/openbb_core/api/router/commands.py index 0ea6fc9e395..7363f54cfb9 100644 --- a/openbb_platform/core/openbb_core/api/router/commands.py +++ b/openbb_platform/core/openbb_core/api/router/commands.py @@ -28,8 +28,13 @@ from openbb_core.env import Env from openbb_core.provider.utils.helpers import to_snake_case CHARTING_INSTALLED = is_installed("openbb_charting") +Charting = None if CHARTING_INSTALLED: - from openbb_charting import Charting # ty: ignore[unresolved-import] + import importlib + + Charting = getattr( + importlib.import_module("openbb_charting"), "Charting" + ) # pragma: no cover T = TypeVar("T") P = ParamSpec("P") @@ -103,7 +108,11 @@ def build_new_signature(path: str, func: Callable) -> Signature: ) ) - if CHARTING_INSTALLED and path.replace("/", "_")[1:] in Charting.functions(): + if ( + CHARTING_INSTALLED + and Charting is not None + and path.replace("/", "_")[1:] in Charting.functions() + ): new_parameter_list.insert( var_kw_pos, Parameter( diff --git a/openbb_platform/core/openbb_core/api/router/coverage.py b/openbb_platform/core/openbb_core/api/router/coverage.py index 07d7e2651e5..915e8da9ffa 100644 --- a/openbb_platform/core/openbb_core/api/router/coverage.py +++ b/openbb_platform/core/openbb_core/api/router/coverage.py @@ -81,7 +81,7 @@ async def get_commands_model_map( """Serialize the object.""" if isinstance(obj, type): return str(obj) - return obj + return obj # pragma: no cover return json.loads(json.dumps(commands_map, default=serializer, indent=4)) diff --git a/openbb_platform/core/openbb_core/api/router/helpers/coverage_helpers.py b/openbb_platform/core/openbb_core/api/router/helpers/coverage_helpers.py index f09fb60ebd6..62be475f9ed 100644 --- a/openbb_platform/core/openbb_core/api/router/helpers/coverage_helpers.py +++ b/openbb_platform/core/openbb_core/api/router/helpers/coverage_helpers.py @@ -14,6 +14,12 @@ if TYPE_CHECKING: provider_interface = ProviderInterface() +def _model_fields(model_or_cls: Any): + """Return pydantic model fields from either a class or an instance.""" + model_cls = model_or_cls if isinstance(model_or_cls, type) else type(model_or_cls) + return model_cls.model_fields + + def get_route_callable(app: "BaseApp", route: str) -> Callable: """Get the callable for a route.""" # TODO: Add return typing Optional[Callable] to this function. First need to @@ -89,7 +95,7 @@ def create_combined_model( model = create_model(model_name, **combined_fields) # # Clean up the metadata - for field in model.model_fields.values(): + for field in _model_fields(model).values(): if hasattr(field, "metadata"): field.metadata = None diff --git a/openbb_platform/core/openbb_core/app/command_runner.py b/openbb_platform/core/openbb_core/app/command_runner.py index 5a8fdc156b6..c8dc52380a0 100644 --- a/openbb_platform/core/openbb_core/app/command_runner.py +++ b/openbb_platform/core/openbb_core/app/command_runner.py @@ -153,9 +153,8 @@ class ParametersBuilder: """Warn if kwargs received and ignored by the validation model.""" # We only check the extra_params annotation because ignored fields # will always be there - annotation = getattr( - model.model_fields.get("extra_params", None), "annotation", None - ) + fields = (model if isinstance(model, type) else type(model)).model_fields + annotation = getattr(fields.get("extra_params", None), "annotation", None) if is_dataclass(annotation) and any( t is ExtraParams for t in getattr(annotation, "__bases__", []) ): diff --git a/openbb_platform/core/openbb_core/app/logs/formatters/formatter_with_exceptions.py b/openbb_platform/core/openbb_core/app/logs/formatters/formatter_with_exceptions.py index f8496d4e04e..8741b10f25b 100644 --- a/openbb_platform/core/openbb_core/app/logs/formatters/formatter_with_exceptions.py +++ b/openbb_platform/core/openbb_core/app/logs/formatters/formatter_with_exceptions.py @@ -11,7 +11,7 @@ class FormatterWithExceptions(logging.Formatter): DATEFORMAT = "%Y-%m-%dT%H:%M:%S%z" LOGFORMAT = "%(asctime)s|%(name)s|%(funcName)s|%(lineno)s|%(message)s" LOGPREFIXFORMAT = ( - "%(levelname)s|%(appName)s|%(commitHash)s|%(appId)s|%(sessionId)s|%(userId)s|" + "%(levelname)s|%(appName)s|%(commitHash)s|%(appId)s|%(sessionId)s|" ) @staticmethod @@ -186,7 +186,6 @@ class FormatterWithExceptions(logging.Formatter): "appId": self.settings.app_id, "sessionId": self.settings.session_id, "commitHash": "unknown-commit", - "userId": self.settings.user_id, } log_extra = self.extract_log_extra(record=record) diff --git a/openbb_platform/core/openbb_core/app/logs/models/logging_settings.py b/openbb_platform/core/openbb_core/app/logs/models/logging_settings.py index e64e971d1d4..039f86078cb 100644 --- a/openbb_platform/core/openbb_core/app/logs/models/logging_settings.py +++ b/openbb_platform/core/openbb_core/app/logs/models/logging_settings.py @@ -25,17 +25,6 @@ class LoggingSettings: if not user_settings.preferences else user_settings.preferences.data_directory ) - hub_session = ( - user_settings.profile.hub_session # ty: ignore[unresolved-attribute] - if user_settings.profile # ty: ignore[unresolved-attribute] - else None - ) - if hub_session: - user_id = hub_session.user_uuid - user_email = hub_session.email - user_primary_usage = hub_session.primary_usage - else: - user_id, user_email, user_primary_usage = None, None, None # System self.app_name: str = system_settings.logging_app_name @@ -51,7 +40,4 @@ class LoggingSettings: self.platform_version: str = system_settings.version self.logging_suppress: bool = system_settings.logging_suppress # User - self.user_id: str | None = user_id self.user_logs_directory: Path = get_log_dir(user_data_directory) - self.user_email: str | None = user_email - self.user_primary_usage: str | None = user_primary_usage diff --git a/openbb_platform/core/openbb_core/app/model/credentials.py b/openbb_platform/core/openbb_core/app/model/credentials.py index 2d560bbb856..ec147aa12f8 100644 --- a/openbb_platform/core/openbb_core/app/model/credentials.py +++ b/openbb_platform/core/openbb_core/app/model/credentials.py @@ -194,8 +194,9 @@ class Credentials(_Credentials): # type: ignore def model_post_init(self, __context) -> None: """Set unset credentials from environment variables.""" super().model_post_init(__context) + model_fields = type(self).model_fields for key, secret in self._env_defaults.items(): - if key not in self.model_fields: + if key not in model_fields: continue current = getattr(self, key, None) if self._is_unset(current): @@ -211,12 +212,14 @@ class Credentials(_Credentials): # type: ignore def show(self): """Unmask credentials and print them.""" + items = [] + for k in sorted(type(self).model_fields): + v = getattr(self, k, None) + if isinstance(v, SecretStr): + v = v.get_secret_value() + items.append(f"{k}: {v}") print( # noqa: T201 - self.__class__.__name__ - + "\n\n" - + "\n".join( - [f"{k}: {v}" for k, v in sorted(self.model_dump(mode="json").items())] - ) + self.__class__.__name__ + "\n\n" + "\n".join(items) ) def update(self, incoming: "Credentials"): diff --git a/openbb_platform/core/openbb_core/app/model/example.py b/openbb_platform/core/openbb_core/app/model/example.py index f513b5dbce9..f6f22b75d2a 100644 --- a/openbb_platform/core/openbb_core/app/model/example.py +++ b/openbb_platform/core/openbb_core/app/model/example.py @@ -47,12 +47,15 @@ class APIEx(Example): def validate_model(cls, values: dict) -> dict: """Validate model.""" parameters = values.get("parameters", {}) - provider = parameters.pop("provider", None) + provider = parameters.get("provider") - if provider and not isinstance(provider, str): + if provider is not None and not isinstance(provider, str): raise ValueError("Provider must be a string.") - if len(parameters) > 3 and not values.get("description"): + # `provider`, when present, is a meta-parameter rather than a real + # query parameter, so don't count it toward the description threshold. + non_provider_count = len(parameters) - (1 if "provider" in parameters else 0) + if non_provider_count > 3 and not values.get("description"): raise ValueError( "Description is required when there are more than 3 parameters." ) diff --git a/openbb_platform/core/openbb_core/app/model/obbject.py b/openbb_platform/core/openbb_core/app/model/obbject.py index 89cc7c68bc7..5f9df7bcb97 100644 --- a/openbb_platform/core/openbb_core/app/model/obbject.py +++ b/openbb_platform/core/openbb_core/app/model/obbject.py @@ -1,5 +1,7 @@ """The OBBject.""" +from __future__ import annotations + from collections.abc import Callable, Hashable from typing import ( TYPE_CHECKING, @@ -20,14 +22,11 @@ from openbb_core.provider.abstract.annotated_result import AnnotatedResult from openbb_core.provider.abstract.data import Data if TYPE_CHECKING: - from numpy import ndarray # noqa - from pandas import DataFrame # noqa - from openbb_core.app.query import Query # noqa + from numpy import ndarray + from pandas import DataFrame + from polars import DataFrame as PolarsDataFrame # ty: ignore[unresolved-import] - try: - from polars import DataFrame as PolarsDataFrame # type: ignore - except ImportError: - PolarsDataFrame = None + from openbb_core.app.query import Query T = TypeVar("T") @@ -82,7 +81,7 @@ class OBBject(Tagged, Generic[T]): index: str | None | None = "date", sort_by: str | None = None, ascending: bool | None = None, - ) -> "DataFrame": + ) -> DataFrame: """Alias for `to_dataframe`. Supports converting creating Pandas DataFrames from the following @@ -122,7 +121,7 @@ class OBBject(Tagged, Generic[T]): index: str | None | None = "date", sort_by: str | None = None, ascending: bool | None = None, - ) -> "DataFrame": + ) -> DataFrame: """Convert results field to Pandas DataFrame. Supports converting creating Pandas DataFrames from the following @@ -166,12 +165,12 @@ class OBBject(Tagged, Generic[T]): isinstance(item, BaseModel) for item in items ) - if self.results is None or not self.results: - raise OpenBBError("Results not found.") - if isinstance(self.results, DataFrame): return self.results + if self.results is None or not self.results: + raise OpenBBError("Results not found.") + try: res = self.results df = None @@ -249,7 +248,7 @@ class OBBject(Tagged, Generic[T]): try: df = DataFrame(res) # type: ignore[call-overload] except ValueError: - if isinstance(res, dict): + if isinstance(res, dict): # pragma: no cover df = DataFrame([res]) if df is None: @@ -287,14 +286,14 @@ class OBBject(Tagged, Generic[T]): return df - def to_polars(self) -> "PolarsDataFrame": + def to_polars(self) -> PolarsDataFrame: """Convert results field to polars dataframe.""" from openbb_core.app.utils_optional import require_optional polars = require_optional("polars") return polars.from_pandas(self.to_dataframe(index=None)) # type: ignore[union-attr] - def to_numpy(self) -> "ndarray": + def to_numpy(self) -> ndarray: """Convert results field to numpy array.""" return self.to_dataframe(index=None).to_numpy() @@ -357,7 +356,7 @@ class OBBject(Tagged, Generic[T]): show_function(**kwargs) @classmethod - async def from_query(cls, query: "Query") -> "OBBject": + async def from_query(cls, query: Query) -> OBBject: """Create OBBject from query. Parameters diff --git a/openbb_platform/core/openbb_core/app/router.py b/openbb_platform/core/openbb_core/app/router.py index 5a2be754a28..d0cead1df34 100644 --- a/openbb_platform/core/openbb_core/app/router.py +++ b/openbb_platform/core/openbb_core/app/router.py @@ -79,11 +79,11 @@ class Router: @overload def command(self, func: Callable[P, OBBject] | None) -> Callable[P, OBBject]: - pass + pass # pragma: no cover @overload def command(self, **kwargs) -> Callable: - pass + pass # pragma: no cover def command( self, @@ -447,7 +447,7 @@ class CommandMap: openapi_extra = getattr(route, "openapi_extra", None) if openapi_extra: model = openapi_extra.get("model", None) - if model: + if model and model in mapping: providers = list(mapping[model].keys()) if "openbb" in providers: providers.remove("openbb") @@ -478,7 +478,7 @@ class CommandMap: openapi_extra = getattr(route, "openapi_extra") if openapi_extra: model = openapi_extra.get("model", None) - if model: + if model and model in mapping: providers = list(mapping[model].keys()) if "openbb" in providers: providers.remove("openbb") diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/builder.py b/openbb_platform/core/openbb_core/app/static/package_builder/builder.py index 501fe28fd26..386760c1e9e 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/builder.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/builder.py @@ -32,7 +32,7 @@ CHARTING_INSTALLED = find_spec("openbb_charting") is not None try: _HAS_FCNTL = True -except Exception: # noqa +except Exception: # pragma: no cover # noqa _HAS_FCNTL = False import msvcrt # noqa @@ -240,7 +240,6 @@ class PackageBuilder: """Run the linters.""" self.console.log("\nRunning linters...") linters = Linters(self.directory / "package", self.verbose) - linters.black() linters.ruff() def _write( diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/class_definition.py b/openbb_platform/core/openbb_core/app/static/package_builder/class_definition.py index 58f67a03a9e..9ba47aa4ba7 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/class_definition.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/class_definition.py @@ -16,7 +16,7 @@ CHARTING_INSTALLED = find_spec("openbb_charting") is not None try: _HAS_FCNTL = True -except Exception: # noqa +except Exception: # pragma: no cover # noqa _HAS_FCNTL = False import msvcrt # noqa diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/docstring_generator.py b/openbb_platform/core/openbb_core/app/static/package_builder/docstring_generator.py index 67e75d3e358..86396257ea9 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/docstring_generator.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/docstring_generator.py @@ -36,9 +36,9 @@ CHARTING_INSTALLED = find_spec("openbb_charting") is not None try: _HAS_FCNTL = True -except Exception: # noqa +except Exception: # noqa # pragma: no cover _HAS_FCNTL = False - import msvcrt # noqa + import msvcrt # noqa # pragma: no cover DataProcessingSupportedTypes = TypeVar( "DataProcessingSupportedTypes", @@ -573,7 +573,7 @@ class DocstringGenerator: literal_choices = re.findall(r"'([^']+)'", literal_content) type_ = format_type(type_) - if "NoneType" in str(type_): + if "NoneType" in str(type_): # pragma: no cover type_ = type_.replace(", NoneType", "") default = getattr(param, "default", "") @@ -860,7 +860,7 @@ class DocstringGenerator: and not doc_has_parameters and [p for p_name, p in formatted_params.items() if p_name != "kwargs"] ): - if result_doc and not result_doc.endswith("\n\n"): + if result_doc and not result_doc.endswith("\n\n"): # pragma: no cover result_doc = result_doc.rstrip("\n") + "\n\n" elif not result_doc: result_doc = "\n\n" @@ -894,7 +894,7 @@ class DocstringGenerator: result_doc += param_section + "\n" if "returns" in sections and not doc_has_returns: - if result_doc and not result_doc.endswith("\n\n"): + if result_doc and not result_doc.endswith("\n\n"): # pragma: no cover result_doc = result_doc.rstrip("\n") + "\n\n" returns_section = "Returns\n-------\n" @@ -920,10 +920,8 @@ class DocstringGenerator: if not is_primitive: try: - if hasattr(type(return_annotation), "model_fields"): - fields = getattr( - type(return_annotation), "model_fields", {} - ) + if hasattr(return_annotation, "model_fields"): + fields = getattr(return_annotation, "model_fields", {}) for field_name, field in fields.items(): field_type = cls.get_field_type( diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/file_lock.py b/openbb_platform/core/openbb_core/app/static/package_builder/file_lock.py index 9fc56a834d3..b0217085816 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/file_lock.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/file_lock.py @@ -18,9 +18,9 @@ try: import fcntl _HAS_FCNTL = True -except Exception: # noqa +except Exception: # noqa # pragma: no cover _HAS_FCNTL = False - import msvcrt # noqa + import msvcrt # noqa # pragma: no cover DataProcessingSupportedTypes = TypeVar( "DataProcessingSupportedTypes", diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/import_definition.py b/openbb_platform/core/openbb_core/app/static/package_builder/import_definition.py index 59469d1fad2..cc77d990f54 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/import_definition.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/import_definition.py @@ -23,7 +23,7 @@ CHARTING_INSTALLED = find_spec("openbb_charting") is not None try: _HAS_FCNTL = True -except Exception: # noqa +except Exception: # pragma: no cover # noqa _HAS_FCNTL = False import msvcrt # noqa diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/method_definition.py b/openbb_platform/core/openbb_core/app/static/package_builder/method_definition.py index b2782f1c25c..7adaef1dee3 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/method_definition.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/method_definition.py @@ -40,13 +40,13 @@ if TYPE_CHECKING: try: from openbb_charting import Charting # type: ignore - CHARTING_INSTALLED = True -except ImportError: + CHARTING_INSTALLED = True # pragma: no cover +except ImportError: # pragma: no cover CHARTING_INSTALLED = False try: _HAS_FCNTL = True -except Exception: # noqa +except Exception: # pragma: no cover # noqa _HAS_FCNTL = False import msvcrt # noqa @@ -292,7 +292,9 @@ class MethodDefinition: field_default = getattr(field, "default", None) if field_default: # Getting json_schema_extra without changing the original dict - json_schema_extra = getattr(field_default, "json_schema_extra", {}).copy() + json_schema_extra = ( + getattr(field_default, "json_schema_extra", {}) or {} + ).copy() json_schema_extra.pop("choices", None) return json_schema_extra return {} @@ -434,14 +436,14 @@ class MethodDefinition: hasattr(meta, "dependency") for meta in param.annotation.__metadata__ ) model = param.annotation.__args__[0] - is_pydantic_model = hasattr(type(model), "model_fields") or hasattr( + is_pydantic_model = hasattr(model, "model_fields") or hasattr( model, "__pydantic_fields__" ) is_get_request = not MethodDefinition.is_data_processing_function(path) if is_pydantic_model and is_get_request and not has_depends: fields = getattr( - type(model), + model, "model_fields", getattr(model, "__pydantic_fields__", {}), ) @@ -1166,7 +1168,7 @@ class MethodDefinition: elif ( isinstance(param.annotation, _AnnotatedAlias) and ( - hasattr(type(param.annotation.__args__[0]), "model_fields") + hasattr(param.annotation.__args__[0], "model_fields") or hasattr(param.annotation.__args__[0], "__pydantic_fields__") ) and not MethodDefinition.is_data_processing_function(path) @@ -1178,7 +1180,7 @@ class MethodDefinition: if not has_depends: model = param.annotation.__args__[0] fields = getattr( - type(model), + model, "model_fields", getattr(model, "__pydantic_fields__", {}), ) diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/module_builder.py b/openbb_platform/core/openbb_core/app/static/package_builder/module_builder.py index f272cd4ce48..6d33ff43568 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/module_builder.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/module_builder.py @@ -16,7 +16,7 @@ CHARTING_INSTALLED = find_spec("openbb_charting") is not None try: _HAS_FCNTL = True -except Exception: # noqa +except Exception: # pragma: no cover # noqa _HAS_FCNTL = False import msvcrt # noqa diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/path_handler.py b/openbb_platform/core/openbb_core/app/static/package_builder/path_handler.py index af404a4c4fb..6fb361d2c1e 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/path_handler.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/path_handler.py @@ -22,7 +22,7 @@ CHARTING_INSTALLED = find_spec("openbb_charting") is not None try: _HAS_FCNTL = True -except Exception: # noqa +except Exception: # pragma: no cover # noqa _HAS_FCNTL = False import msvcrt # noqa diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/reference_generator.py b/openbb_platform/core/openbb_core/app/static/package_builder/reference_generator.py index a3f61a5e823..3aeaa1eb6b8 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/reference_generator.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/reference_generator.py @@ -34,9 +34,9 @@ CHARTING_INSTALLED = find_spec("openbb_charting") is not None try: _HAS_FCNTL = True -except Exception: # noqa +except Exception: # noqa # pragma: no cover _HAS_FCNTL = False - import msvcrt # noqa + import msvcrt # noqa # pragma: no cover DataProcessingSupportedTypes = TypeVar( "DataProcessingSupportedTypes", @@ -588,7 +588,7 @@ class ReferenceGenerator: if hasattr(meta, "default"): default = meta.default if hasattr(meta, "json_schema_extra"): - json_extra = meta.json_schema_extra + json_extra = meta.json_schema_extra or {} # Set the actual type to the base type param_type = base_type @@ -943,11 +943,11 @@ class ReferenceGenerator: try: module = sys.modules[route_func.__module__] model_class = getattr(module, extracted_model_name, None) - if model_class and hasattr(type(model_class), "model_fields"): + if model_class and hasattr(model_class, "model_fields"): # Set data to the fields reference[path]["data"]["standard"] = [] for field_name, field in getattr( - type(model_class), "model_fields", {} + model_class, "model_fields", {} ).items(): field_type = DocstringGenerator.get_field_type( field.annotation, field.is_required(), "website" diff --git a/openbb_platform/core/openbb_core/app/static/utils/decorators.py b/openbb_platform/core/openbb_core/app/static/utils/decorators.py index 3bd1b68229c..b21a3f3de99 100644 --- a/openbb_platform/core/openbb_core/app/static/utils/decorators.py +++ b/openbb_platform/core/openbb_core/app/static/utils/decorators.py @@ -17,12 +17,12 @@ R = TypeVar("R") @overload def validate(func: Callable[P, R]) -> Callable[P, R]: - pass + pass # pragma: no cover @overload def validate(**dec_kwargs) -> Callable[[Callable[P, R]], Callable[P, R]]: - pass + pass # pragma: no cover def validate( @@ -104,6 +104,4 @@ def exception_handler(func: Callable[P, R]) -> Callable[P, R]: f"\n[Unexpected Error] -> {e.__class__.__name__} -> {e}" ).with_traceback(tb) from None - return None - return wrapper diff --git a/openbb_platform/core/openbb_core/app/static/utils/linters.py b/openbb_platform/core/openbb_core/app/static/utils/linters.py index eba5ade4f2b..dcbb6309087 100644 --- a/openbb_platform/core/openbb_core/app/static/utils/linters.py +++ b/openbb_platform/core/openbb_core/app/static/utils/linters.py @@ -3,12 +3,8 @@ import shutil import subprocess from pathlib import Path -from typing import ( - Literal, -) from openbb_core.app.static.utils.console import Console -from openbb_core.env import Env class Linters: @@ -26,7 +22,7 @@ class Linters: def run( self, - linter: Literal["black", "ruff"], + linter: str, flags: list[str] | None = None, ): """Run linter with flags.""" @@ -36,7 +32,7 @@ class Linters: command = [linter] if flags: - command.extend(flags) # type: ignore + command.extend(flags) subprocess.run( # noqa: S603 command + list(self.directory.glob("*.py")), check=False ) @@ -45,18 +41,9 @@ class Linters: else: self.console.log(f"\n* {linter} not found") - def black(self): - """Run black.""" - flags = ["--line-length", "122"] - if not self.verbose and not Env().DEBUG_MODE: - flags.append("--quiet") - self.run(linter="black", flags=flags) - def ruff(self): """Run ruff.""" - self.black() flags = ["check", "--fix", "--unsafe-fixes"] - if not self.verbose and not Env().DEBUG_MODE: + if not self.verbose: flags.append("--silent") self.run(linter="ruff", flags=flags) - self.black() diff --git a/openbb_platform/core/openbb_core/provider/abstract/fetcher.py b/openbb_platform/core/openbb_core/provider/abstract/fetcher.py index bdbd37b2735..6004c379618 100644 --- a/openbb_platform/core/openbb_core/provider/abstract/fetcher.py +++ b/openbb_platform/core/openbb_core/provider/abstract/fetcher.py @@ -92,7 +92,7 @@ class Fetcher(Generic[Q, R]): def return_type(self) -> R: """Get the type of return.""" return_type = self.__orig_bases__[0].__args__[1] # type: ignore - if get_origin(return_type) is AnnotatedResult: + if get_origin(return_type) is AnnotatedResult: # pragma: no cover return_type = get_args(return_type)[0] return return_type @@ -140,6 +140,7 @@ class Fetcher(Generic[Q, R]): cls.extract_data, query=query, credentials=credentials, **kwargs ) result = cls.transform_data(query=query, data=data, **kwargs) + data_type_fields = cls.data_type.model_fields # Class Assertions assert isinstance(cls.require_credentials, bool), ( @@ -163,11 +164,9 @@ class Fetcher(Generic[Q, R]): is_list = isinstance(data, list) if is_list: assert all( - field in data[0] - for field in cls.data_type.model_fields - if field in data[0] + field in data[0] for field in data_type_fields if field in data[0] ), ( - f"Data must have the correct fields. Expected: {cls.data_type.model_fields} Got: {data[0].__dict__}" + f"Data must have the correct fields. Expected: {data_type_fields} Got: {data[0].__dict__}" ) # This makes sure that the data is not transformed yet so that the # pipeline is implemented correctly. We can remove this assertion if we @@ -176,10 +175,8 @@ class Fetcher(Generic[Q, R]): f"Data must not be transformed yet. Expected: {cls.data_type} Got: {type(data[0])}" ) else: - assert all( - field in data for field in cls.data_type.model_fields if field in data - ), ( - f"Data must have the correct fields. Expected: {cls.data_type.model_fields} Got: {data.__dict__}" + assert all(field in data for field in data_type_fields if field in data), ( + f"Data must have the correct fields. Expected: {data_type_fields} Got: {data.__dict__}" ) assert issubclass(type(data), cls.data_type) is False, ( f"Data must not be transformed yet. Expected: {cls.data_type} Got: {type(data)}" @@ -200,13 +197,17 @@ class Fetcher(Generic[Q, R]): hasattr(return_type_args, "__origin__") and return_type_args.__origin__ is dict ) - if return_type_is_dict: + if return_type_is_dict: # pragma: no cover return_type_fields = ( return_type_args.__args__[1].__args__[0].model_fields ) return_type = return_type_args.__args__[1].__args__[0] else: - return_type_fields = return_type_args.model_fields + return_type_fields = ( + return_type_args + if isinstance(return_type_args, type) + else type(return_type_args) + ).model_fields return_type = return_type_args assert len(transformed_data) > 0, "Transformed data must not be empty." @@ -228,11 +229,11 @@ class Fetcher(Generic[Q, R]): f"Transformed data must be of the correct type. Expected: {return_type} Got: {type(transformed_data[0])}" ) else: + return_type_fields = cls.return_type.model_fields assert all( - field in transformed_data.__dict__ - for field in cls.return_type.model_fields + field in transformed_data.__dict__ for field in return_type_fields ), ( - f"Transformed data must have the correct fields. Expected: {cls.return_type.model_fields} Got: {transformed_data.__dict__}" + f"Transformed data must have the correct fields. Expected: {return_type_fields} Got: {transformed_data.__dict__}" ) assert issubclass(type(transformed_data), cls.data_type), ( f"Transformed data must be of the correct type. Expected: {cls.data_type} Got: {type(transformed_data)}" diff --git a/openbb_platform/core/openbb_core/provider/registry_map.py b/openbb_platform/core/openbb_core/provider/registry_map.py index 8aa2349bde9..f9184f3a382 100644 --- a/openbb_platform/core/openbb_core/provider/registry_map.py +++ b/openbb_platform/core/openbb_core/provider/registry_map.py @@ -18,6 +18,12 @@ STANDARD_MODELS_FOLDER = Path(__file__).parent / "standard_models" SKIP = {"object", "Representation", "BaseModel", "QueryParams", "Data"} +def _model_fields(model_or_cls: Any): + """Return pydantic model fields from either a class or an instance.""" + model_cls = model_or_cls if isinstance(model_or_cls, type) else type(model_or_cls) + return model_cls.model_fields + + class RegistryMap: """Class to store information about providers in the registry.""" @@ -159,7 +165,7 @@ class RegistryMap: fields = { name: field - for name, field in child.model_fields.items() + for name, field in _model_fields(child).items() # This ensures fields inherited by c are discarded. # We need to compare child and parent __annotations__ # because this attribute is redirected to the parent class diff --git a/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py b/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py index 2eeaf400f1a..5bc825a690b 100644 --- a/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py +++ b/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py @@ -364,7 +364,7 @@ class OptionsChainsData(OptionsChainsProperties): def model_serialize(self): """Return the serialized data.""" data: dict = {} - for field in self.model_fields: + for field in type(self).model_fields: value = getattr(self, field) if isinstance(value, list): if value: # Check if the list is not empty @@ -373,7 +373,7 @@ class OptionsChainsData(OptionsChainsProperties): else: data[field] = value else: - data[field] = value + data[field] = value # pragma: no cover records = [dict(zip(data.keys(), values)) for values in zip(*data.values())] @@ -438,6 +438,6 @@ class OptionsChainsData(OptionsChainsProperties): "items": items_schema, "title": cls.__name__, } - if defs: + if defs: # pragma: no cover schema["$defs"] = defs return schema diff --git a/openbb_platform/core/openbb_core/provider/utils/helpers.py b/openbb_platform/core/openbb_core/provider/utils/helpers.py index e8a88e5a9f2..2e4cbb96f14 100644 --- a/openbb_platform/core/openbb_core/provider/utils/helpers.py +++ b/openbb_platform/core/openbb_core/provider/utils/helpers.py @@ -623,7 +623,7 @@ def filter_by_dates( return dt >= start_date if end_date: return dt <= end_date - return True + return True # pragma: no cover return False return list(filter(_filter, data)) # ty: ignore[invalid-return-type] diff --git a/openbb_platform/core/openbb_core/provider/utils/options_chains_properties.py b/openbb_platform/core/openbb_core/provider/utils/options_chains_properties.py index dc5348ed76e..0be439c89ab 100644 --- a/openbb_platform/core/openbb_core/provider/utils/options_chains_properties.py +++ b/openbb_platform/core/openbb_core/provider/utils/options_chains_properties.py @@ -1704,7 +1704,7 @@ class OptionsChainsProperties(Data): moneyness = 20 if date is None: - date = 30 + date = 30 # pragma: no cover if date == -1: date = None diff --git a/openbb_platform/core/pyproject.toml b/openbb_platform/core/pyproject.toml index 4c24e12a114..6a00bf988bf 100644 --- a/openbb_platform/core/pyproject.toml +++ b/openbb_platform/core/pyproject.toml @@ -45,13 +45,6 @@ include = [ "pyproject.toml", ] -# --------------------------------------------------------------------------- -# Ruff — sole linter / formatter / import-sorter for openbb-core. -# Mirrors the repo-wide ``ruff.toml`` but is scoped to this package so that -# ``ruff check openbb_platform/core`` (and editor integrations launched from -# this directory) pick up identical settings without depending on the -# top-level configuration being discoverable. -# --------------------------------------------------------------------------- [tool.ruff] line-length = 88 target-version = "py310" @@ -127,9 +120,6 @@ max-statements = 30 [tool.ruff.lint.pydocstyle] convention = "numpy" -# --------------------------------------------------------------------------- -# ty — Astral's static type checker (replaces mypy across the repo). -# --------------------------------------------------------------------------- [tool.ty] [tool.ty.environment] python-version = "3.10" diff --git a/openbb_platform/core/tests/api/router/__init__.py b/openbb_platform/core/tests/api/router/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openbb_platform/core/tests/api/router/helpers/__init__.py b/openbb_platform/core/tests/api/router/helpers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openbb_platform/core/tests/api/router/helpers/test_coverage_helpers.py b/openbb_platform/core/tests/api/router/helpers/test_coverage_helpers.py new file mode 100644 index 00000000000..aa8e294650c --- /dev/null +++ b/openbb_platform/core/tests/api/router/helpers/test_coverage_helpers.py @@ -0,0 +1,193 @@ +"""Tests for openbb_core.api.router.helpers.coverage_helpers.""" + +from dataclasses import ( + field as dc_field, + make_dataclass, +) +from typing import Annotated + +import pytest +from pydantic import BaseModel, Field +from pydantic.fields import FieldInfo + +from openbb_core.api.router.helpers import coverage_helpers +from openbb_core.api.router.helpers.coverage_helpers import ( + create_combined_model, + dataclass_to_fields, + get_route_callable, + get_route_schema_map, + signature_to_fields, +) + + +class _FakeApp: + class equity: # noqa: N801 + @staticmethod + def profile(symbol: str, limit: int = 5): + return {"symbol": symbol, "limit": limit} + + +def test_get_route_callable_walks_dotted_path(): + fn = get_route_callable(_FakeApp, "/equity/profile") + assert fn is _FakeApp.equity.profile + + +def test_get_route_callable_supports_dot_path(): + fn = get_route_callable(_FakeApp, ".equity.profile") + assert fn is _FakeApp.equity.profile + + +def test_signature_to_fields_extracts_typed_params(): + fields = signature_to_fields(_FakeApp, "/equity/profile") + assert "symbol" in fields and "limit" in fields + sym_type, sym_field = fields["symbol"] + assert sym_type is str + assert sym_field.title == "openbb" + + +class _A(BaseModel): + x: int = Field(default=1, title="openbb", description="x") + + +class _B(BaseModel): + y: int = Field(default=2, title="other", description="y") + + +def test_create_combined_model_no_filter_keeps_all(): + fields_a = {"x": (int, Field(..., title="openbb", description="x"))} + fields_b = {"y": (int, Field(..., title="other", description="y"))} + model = create_combined_model("M", fields_a, fields_b) + model_fields = (model if isinstance(model, type) else type(model)).model_fields + assert {"x", "y"} <= set(model_fields) + + +def test_create_combined_model_filters_by_provider(): + fields_a = {"x": (int, Field(..., title="openbb", description=""))} + fields_b = {"y": (int, Field(..., title="fmp", description=""))} + fields_c = {"z": (int, Field(..., title="polygon", description=""))} + model = create_combined_model( + "M", fields_a, fields_b, fields_c, filter_by_provider="fmp" + ) + model_fields = (model if isinstance(model, type) else type(model)).model_fields + assert "x" in model_fields # always kept (openbb) + assert "y" in model_fields # matches provider + assert "z" not in model_fields + + +def test_signature_to_fields_with_annotated_description(): + class _App: + class router: # noqa: N801 + @staticmethod + def cmd( + ticker: Annotated[str, type("M", (), {"description": "the ticker"})()], + ): + return ticker + + fields = signature_to_fields(_App, "/router/cmd") + # Annotated metadata description should be picked up. + _, fld = fields["ticker"] + # Either present or None depending on metadata layout; just confirm field exists. + assert fld.title == "openbb" + + +def test_get_route_callable_attribute_error_propagates(): + with pytest.raises(AttributeError): + get_route_callable(_FakeApp, "/does/not/exist") + + +def _make_extra_dataclass(): + """Build a dataclass shaped like ProviderInterface.params[model]['extra'].""" + fi_x = FieldInfo(default=None, title="openbb", description="x desc", annotation=int) + fi_y = FieldInfo(default=None, title="fmp", description="y desc", annotation=str) + return make_dataclass( + "ExtraDC", + [ + ("x", int, dc_field(default=fi_x)), + ("y", str, dc_field(default=fi_y)), + ], + ) + + +class _OutputModel(BaseModel): + """Pydantic output model used for the schema map.""" + + val: int = 0 + + +def test_dataclass_to_fields_extracts_field_info(monkeypatch): + DC = _make_extra_dataclass() + monkeypatch.setattr( + coverage_helpers.provider_interface, "_params", {"M": {"extra": DC}} + ) + fields = dataclass_to_fields("M") + assert set(fields) == {"x", "y"} + x_type, x_field = fields["x"] + assert x_type is int + assert x_field.title == "openbb" + assert x_field.description == "x desc" + + +def test_get_route_schema_map_combines_signature_and_dataclass(monkeypatch): + DC = _make_extra_dataclass() + monkeypatch.setattr( + coverage_helpers.provider_interface, "_params", {"M": {"extra": DC}} + ) + monkeypatch.setattr( + coverage_helpers.provider_interface, + "_return_schema", + {"M": _OutputModel}, + ) + out = get_route_schema_map(_FakeApp, {"/equity/profile": "M"}) + assert "/equity/profile" in out + entry = out["/equity/profile"] + assert entry["output"] is _OutputModel + assert entry["callable"] is _FakeApp.equity.profile + input_fields = ( + entry["input"] if isinstance(entry["input"], type) else type(entry["input"]) + ).model_fields + # signature params (symbol, limit) + dataclass extras (x, y) all merged + assert {"symbol", "limit", "x", "y"} <= set(input_fields) + + +def test_get_route_schema_map_filters_by_provider(monkeypatch): + DC = _make_extra_dataclass() + monkeypatch.setattr( + coverage_helpers.provider_interface, "_params", {"M": {"extra": DC}} + ) + monkeypatch.setattr( + coverage_helpers.provider_interface, + "_return_schema", + {"M": _OutputModel}, + ) + out = get_route_schema_map( + _FakeApp, {"/equity/profile": "M"}, filter_by_provider="fmp" + ) + input_model = out["/equity/profile"]["input"] + fields = ( + input_model if isinstance(input_model, type) else type(input_model) + ).model_fields + # x has title "openbb" -> always kept; y has title "fmp" -> kept + assert {"x", "y"} <= set(fields) + + +def test_get_route_schema_map_filter_excludes_non_matching_provider(monkeypatch): + DC = _make_extra_dataclass() + monkeypatch.setattr( + coverage_helpers.provider_interface, "_params", {"M": {"extra": DC}} + ) + monkeypatch.setattr( + coverage_helpers.provider_interface, + "_return_schema", + {"M": _OutputModel}, + ) + out = get_route_schema_map( + _FakeApp, {"/equity/profile": "M"}, filter_by_provider="polygon" + ) + input_model = out["/equity/profile"]["input"] + fields = ( + input_model if isinstance(input_model, type) else type(input_model) + ).model_fields + # y had title "fmp" — should NOT appear when filtering for polygon + assert "y" not in fields + # x had title "openbb" — always kept + assert "x" in fields diff --git a/openbb_platform/core/tests/api/test_auth/test_user_auth.py b/openbb_platform/core/tests/api/test_auth/test_user_auth.py index 7544912ccad..c2a1c19cc31 100644 --- a/openbb_platform/core/tests/api/test_auth/test_user_auth.py +++ b/openbb_platform/core/tests/api/test_auth/test_user_auth.py @@ -146,15 +146,6 @@ def test_get_user_settings_returns_defaults_when_file_missing(tmp_path: Path): assert isinstance(result, UserSettings) -# -------------------------- HTTP integration tests -------------------------- -# -# The tests below mount the real auth dependencies on a real FastAPI app and -# drive them through the real HTTP stack via ``TestClient``. They verify that -# the dependency wiring actually rejects/accepts requests end-to-end (status -# code, body, ``WWW-Authenticate`` header), not just the unit behavior of the -# individual coroutines. - - def _build_app_with_auth_enabled(monkeypatch, username: str, password: str): """Reload the auth module with ``OPENBB_API_AUTH=True`` and mount its hooks. diff --git a/openbb_platform/core/tests/api/test_exception_handlers.py b/openbb_platform/core/tests/api/test_exception_handlers.py new file mode 100644 index 00000000000..813f48929e0 --- /dev/null +++ b/openbb_platform/core/tests/api/test_exception_handlers.py @@ -0,0 +1,186 @@ +"""Tests for openbb_core.api.exception_handlers.""" + +import json +from unittest.mock import MagicMock + +import pytest +from fastapi.exceptions import ResponseValidationError +from pydantic import BaseModel, ValidationError + +from openbb_core.api.exception_handlers import ExceptionHandlers +from openbb_core.app.model.abstract.error import OpenBBError +from openbb_core.provider.utils.errors import EmptyDataError, UnauthorizedError + + +def _make_request(query_params: dict | None = None) -> MagicMock: + req = MagicMock() + req.query_params = query_params or {} + return req + + +@pytest.mark.asyncio +async def test_handle_returns_json_response_when_not_debug(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + resp = await ExceptionHandlers._handle(ValueError("boom"), 500, "boom") + assert resp.status_code == 500 + assert json.loads(resp.body) == {"detail": "boom"} + + +@pytest.mark.asyncio +async def test_handle_reraises_in_debug_mode(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": True}) + ) + with pytest.raises(ValueError, match="boom"): + await ExceptionHandlers._handle(ValueError("boom"), 500, "boom") + + +@pytest.mark.asyncio +async def test_exception_handler_value_error(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + err = ValueError("bad") + err.errors = err # type: ignore[attr-defined] + resp = await ExceptionHandlers.exception(_make_request(), err) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_exception_handler_unknown_falls_back_to_500(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + class Boom(Exception): + pass + + resp = await ExceptionHandlers.exception(_make_request(), Boom("oops")) + assert resp.status_code == 500 + body = json.loads(resp.body) + assert "Boom" in body["detail"] + + +@pytest.mark.asyncio +async def test_exception_handler_iterable_missing(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + class HasErrors(Exception): + errors = [{"type": "missing", "loc": ("symbol",)}] + + resp = await ExceptionHandlers.exception(_make_request(), HasErrors()) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_validation_handler_query_params_routed_to_query_loc(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + class MyQueryParams(BaseModel): + symbol: str + + try: + MyQueryParams() # type: ignore[call-arg] + except ValidationError as ve: + err = ve + + req = _make_request({"symbol": ""}) # ensure all_in_query short-circuit works + # Force `error.title` to contain "QueryParams"; pydantic provides title via model name. + resp = await ExceptionHandlers.validation(req, err) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_validation_handler_response_validation_error(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + err = ResponseValidationError( + [ + { + "type": "value_error", + "loc": ("body", "field"), + "msg": "bad", + "ctx": {"x": 1}, + } + ], + body={}, + ) + + resp = await ExceptionHandlers.validation(_make_request(), err) + assert resp.status_code == 422 + detail = json.loads(resp.body)["detail"] + assert detail[0]["loc"] == ["query", "body", "field"] + assert "ctx" not in detail[0] + + +@pytest.mark.asyncio +async def test_validation_handler_errors_fallback_when_include_url_raises(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + class BrokenValidationError(Exception): + title = "NotQueryParams" + + def errors(self, include_url=False): + raise RuntimeError("boom") + + err = BrokenValidationError("bad") + err.errors = [{"type": "other", "loc": ("field",), "msg": "bad"}] # type: ignore[assignment] + + resp = await ExceptionHandlers.validation(_make_request(), err) # type: ignore[arg-type] + assert resp.status_code == 500 + + +@pytest.mark.asyncio +async def test_validation_handler_non_queryparams_falls_back_to_exception(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + + class NonQueryValidationError(Exception): + title = "SomeOtherModel" + + def errors(self, include_url=False): + return [{"type": "value_error", "loc": ("field",), "msg": "bad"}] + + resp = await ExceptionHandlers.validation( + _make_request({"other": "x"}), + NonQueryValidationError("bad"), # type: ignore[arg-type] + ) + assert resp.status_code == 500 + + +@pytest.mark.asyncio +async def test_openbb_handler(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + err = OpenBBError("bad input") + resp = await ExceptionHandlers.openbb(_make_request(), err) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_empty_data_handler(): + resp = await ExceptionHandlers.empty_data(_make_request(), EmptyDataError("empty")) + assert resp.status_code == 204 + + +@pytest.mark.asyncio +async def test_unauthorized_handler(monkeypatch): + monkeypatch.setattr( + "openbb_core.api.exception_handlers.Env", type("E", (), {"DEBUG_MODE": False}) + ) + err = UnauthorizedError("nope") + resp = await ExceptionHandlers.unauthorized(_make_request(), err) + assert resp.status_code == 502 diff --git a/openbb_platform/core/tests/api/test_rest_api.py b/openbb_platform/core/tests/api/test_rest_api.py index 800e2740794..d4ed40904d7 100644 --- a/openbb_platform/core/tests/api/test_rest_api.py +++ b/openbb_platform/core/tests/api/test_rest_api.py @@ -13,8 +13,11 @@ of the assembled application: whenever any command route is mounted, the coverage routes are too. """ +import asyncio + from fastapi.testclient import TestClient +from openbb_core.api import rest_api from openbb_core.api.rest_api import app, system @@ -86,3 +89,46 @@ def test_router_inclusion_invariant_commands_imply_coverage(): assert has_coverage, ( f"command routes mounted without coverage routes; mounted: {paths}" ) + + +def test_lifespan_logs_banner_with_auth_enabled(monkeypatch): + logged = {} + + monkeypatch.setattr( + rest_api, + "Env", + type("E", (), {"API_AUTH": True}), + ) + monkeypatch.setattr( + rest_api.logger, "info", lambda message: logged.setdefault("msg", message) + ) + + async def _run(): + async with rest_api.lifespan(app): + return None + + asyncio.run(_run()) + + assert "Authentication: ENABLED" in logged["msg"] + assert system.version in logged["msg"] + + +def test_lifespan_logs_banner_with_auth_disabled(monkeypatch): + logged = {} + + monkeypatch.setattr( + rest_api, + "Env", + type("E", (), {"API_AUTH": False}), + ) + monkeypatch.setattr( + rest_api.logger, "info", lambda message: logged.setdefault("msg", message) + ) + + async def _run(): + async with rest_api.lifespan(app): + return None + + asyncio.run(_run()) + + assert "Authentication: DISABLED" in logged["msg"] diff --git a/openbb_platform/core/tests/api/test_router/test_commands_helpers.py b/openbb_platform/core/tests/api/test_router/test_commands_helpers.py new file mode 100644 index 00000000000..0081d22b371 --- /dev/null +++ b/openbb_platform/core/tests/api/test_router/test_commands_helpers.py @@ -0,0 +1,436 @@ +"""Targeted tests for ``openbb_core.api.router.commands`` helpers.""" + +import runpy +import sys +from inspect import signature +from typing import Annotated +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import Depends +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute +from pydantic import BaseModel, Field + +from openbb_core.api.router.commands import ( + add_command_map, + build_api_wrapper, + build_new_annotation_map, + build_new_signature, + validate_output, +) +from openbb_core.app.command_runner import CommandRunner +from openbb_core.app.model.command_context import CommandContext +from openbb_core.app.model.obbject import OBBject + + +def test_build_new_annotation_map_includes_return(): + def f(a: int, b: str = "x") -> bool: + return True + + out = build_new_annotation_map(signature(f)) + assert out["a"] is int + assert out["b"] is str + assert out["return"] is bool + + +def test_commands_module_imports_charting_when_installed(monkeypatch): + from openbb_core.app import utils_optional + + class _Charting: + @staticmethod + def functions(): + return [] + + with pytest.MonkeyPatch.context() as m: + m.setattr( + utils_optional, "is_installed", lambda name: name == "openbb_charting" + ) + m.setitem( + sys.modules, "openbb_charting", type("M", (), {"Charting": _Charting})() + ) + module_ns = runpy.run_module( + "openbb_core.api.router.commands", run_name="__test_commands_charting__" + ) + assert module_ns["CHARTING_INSTALLED"] is True + assert module_ns["Charting"] is _Charting + + +def test_build_new_signature_skips_command_context_and_kwargs(): + def f(cc: CommandContext, x: int, **kwargs) -> None: ... + + new_sig = build_new_signature("/some/path", f) + names = [p.name for p in new_sig.parameters.values()] + assert "cc" not in names + assert "kwargs" not in names + assert "x" in names + + +def test_build_new_signature_handles_depends_metadata(): + """Annotated[..., Depends(...)] params get re-injected at var_kw_pos.""" + + def dep(): + return 1 + + def f(x: int, y: Annotated[int, Depends(dep)] = 0) -> None: ... + + new_sig = build_new_signature("/path", f) + names = [p.name for p in new_sig.parameters.values()] + assert "x" in names and "y" in names + + +def test_build_new_signature_inserts_custom_headers(monkeypatch): + from openbb_core.api.router import commands as cmds + + fake_settings = MagicMock() + fake_settings.system_settings.api_settings.custom_headers = {"X-Test": "default"} + monkeypatch.setattr(cmds, "SystemService", lambda: fake_settings) + + def f(x: int) -> None: ... + + new_sig = build_new_signature("/p", f) + names = [p.name for p in new_sig.parameters.values()] + assert "X_Test" in names + + +def test_build_new_signature_inserts_auth_param_when_api_auth(monkeypatch): + from openbb_core.api.router import commands as cmds + + monkeypatch.setattr(cmds, "Env", type("E", (), {"API_AUTH": True})) + + def f(x: int) -> None: ... + + new_sig = build_new_signature("/p", f) + names = [p.name for p in new_sig.parameters.values()] + assert "__authenticated_user_settings" in names + + +def test_build_new_signature_inserts_chart_param_when_charting_enabled(monkeypatch): + from openbb_core.api.router import commands as cmds + + monkeypatch.setattr(cmds, "CHARTING_INSTALLED", True) + monkeypatch.setattr( + cmds, + "Charting", + type("Charting", (), {"functions": staticmethod(lambda: ["api_v1_chart"])})(), + raising=False, + ) + + def f(x: int, **extras) -> None: ... + + new_sig = build_new_signature("/api/v1/chart", f) + names = [p.name for p in new_sig.parameters.values()] + assert "chart" in names + + +class _NestedExcl(BaseModel): + nested_secret: int = Field(default=0, json_schema_extra={"exclude_from_api": True}) + visible: int = 0 + + +class _OuterModel(BaseModel): + outer_secret: int = Field(default=0, json_schema_extra={"exclude_from_api": True}) + inner: _NestedExcl = _NestedExcl() + public: int = 1 + + +def test_validate_output_passthrough_non_obbject(): + """A non-OBBject value is returned unchanged.""" + obj = {"x": 1} + out = validate_output(obj) # type: ignore[arg-type] + assert out is obj + + +def test_validate_output_excludes_top_level_field_marked_exclude_from_api(): + class _OBBWithSecret(OBBject): + secret_thing: int = Field( + default=0, json_schema_extra={"exclude_from_api": True} + ) + + out = _OBBWithSecret(results=[{"a": 1}], secret_thing=42) + validate_output(out) + assert not hasattr(out, "secret_thing") + + +def test_validate_output_excludes_nested_basemodel_fields(): + class _OBBNested(OBBject): + outer: _OuterModel = _OuterModel() + + out = _OBBNested(results=[{"a": 1}], outer=_OuterModel()) + validate_output(out) + # nested exclusion happens; the remaining structure stays intact + assert hasattr(out, "outer") + + +def _make_route(endpoint, path="/api/v1/x"): + return APIRoute(path=path, endpoint=endpoint, methods=["GET"]) + + +@pytest.mark.asyncio +async def test_wrapper_returns_jsonresponse_when_results_only(): + runner = MagicMock(spec=CommandRunner) + + async def _run(path, user_settings, *args, **kwargs): + out = OBBject(results=[{"a": 1}]) + out._results_only = True # type: ignore[attr-defined] + return out + + runner.run = _run + + async def endpoint(symbol: str = "AAPL", **kwargs): + return OBBject(results=[{"symbol": symbol}]) + + route = _make_route(endpoint) + wrapper = build_api_wrapper(runner, route) + out = await wrapper(symbol="AAPL") + assert isinstance(out, JSONResponse) + assert out.status_code == 200 + + +@pytest.mark.asyncio +async def test_wrapper_returns_jsonresponse_when_extension_modified(): + runner = MagicMock(spec=CommandRunner) + + async def _run(path, user_settings, *args, **kwargs): + out = OBBject(results=[{"a": 1}]) + out._extension_modified = True # type: ignore[attr-defined] + return out + + runner.run = _run + + async def endpoint(symbol: str = "AAPL", **kwargs): + return OBBject(results=[{"symbol": symbol}]) + + route = _make_route(endpoint) + wrapper = build_api_wrapper(runner, route) + out = await wrapper(symbol="AAPL") + assert isinstance(out, JSONResponse) + + +@pytest.mark.asyncio +async def test_wrapper_passes_through_non_obbject(): + runner = MagicMock(spec=CommandRunner) + + async def _run(path, user_settings, *args, **kwargs): + return {"plain": "dict"} + + runner.run = _run + + async def endpoint(symbol: str = "AAPL", **kwargs): + return OBBject(results=[{"symbol": symbol}]) + + route = _make_route(endpoint) + wrapper = build_api_wrapper(runner, route) + out = await wrapper(symbol="AAPL") + assert out == {"plain": "dict"} + + +@pytest.mark.asyncio +async def test_wrapper_no_validate_route_returns_jsonresponse(): + runner = MagicMock(spec=CommandRunner) + + async def _run(path, user_settings, *args, **kwargs): + return OBBject(results=[{"a": 1}]) + + runner.run = _run + + async def endpoint(symbol: str = "AAPL", **kwargs): + return OBBject(results=[{"symbol": symbol}]) + + route = _make_route(endpoint) + route.openapi_extra = {"no_validate": True} + wrapper = build_api_wrapper(runner, route) + out = await wrapper(symbol="AAPL") + assert isinstance(out, JSONResponse) + + +@pytest.mark.asyncio +async def test_wrapper_applies_user_defaults_to_standard_params(): + """User defaults populate empty standard_params keys.""" + from dataclasses import dataclass + + from openbb_core.app.model.user_settings import UserSettings + + @dataclass + class _SP: + symbol: str | None = None + limit: int | None = None + + runner = MagicMock(spec=CommandRunner) + captured = {} + + async def _run(path, user_settings, *args, **kwargs): + captured["kwargs"] = kwargs + return OBBject(results=[{"a": 1}]) + + runner.run = _run + + async def endpoint(standard_params=None, extra_params=None, **kwargs): + return OBBject(results=[{"a": 1}]) + + route = _make_route(endpoint) + wrapper = build_api_wrapper(runner, route) + + fake_user_settings = UserSettings() + fake_user_settings.defaults.commands = { # type: ignore[attr-defined] + "api.v1.x": {"symbol": "AAPL", "limit": 10} + } + out = await wrapper( + standard_params=_SP(), + extra_params=_SP(), + __authenticated_user_settings=fake_user_settings, + ) + assert isinstance(out, OBBject) + assert captured["kwargs"]["standard_params"]["symbol"] == "AAPL" + + +@pytest.mark.asyncio +async def test_wrapper_injects_dependencies_into_kwargs(): + """Route dependencies are injected into ``kwargs``-accepting endpoints.""" + + def get_thing(): + return "the-thing" + + runner = MagicMock(spec=CommandRunner) + captured = {} + + async def _run(path, user_settings, *args, **kwargs): + captured["kwargs"] = kwargs + return OBBject(results=[{"a": 1}]) + + runner.run = _run + + async def endpoint(**kwargs): + return OBBject(results=[{"a": 1}]) + + route = APIRoute( + path="/api/v1/x", + endpoint=endpoint, + methods=["GET"], + dependencies=[Depends(get_thing)], + ) + wrapper = build_api_wrapper(runner, route) + await wrapper() + assert captured["kwargs"]["kwargs"]["thing"] == "the-thing" + + +@pytest.mark.asyncio +async def test_wrapper_skips_none_dependency_callable(): + runner = MagicMock(spec=CommandRunner) + captured = {} + + async def _run(path, user_settings, *args, **kwargs): + captured["kwargs"] = kwargs + return OBBject(results=[{"a": 1}]) + + runner.run = _run + + async def endpoint(**kwargs): + return OBBject(results=[{"a": 1}]) + + route = APIRoute(path="/api/v1/x", endpoint=endpoint, methods=["GET"]) + route.dependencies = [Depends(None)] + wrapper = build_api_wrapper(runner, route) + await wrapper() + assert captured["kwargs"]["kwargs"] == {} + + +@pytest.mark.asyncio +async def test_wrapper_defaults_do_not_override_existing_values(): + from dataclasses import dataclass + + from openbb_core.app.model.user_settings import UserSettings + + @dataclass + class _SP: + symbol: str | None = "MSFT" + + @dataclass + class _EP: + limit: int | None = 5 + + runner = MagicMock(spec=CommandRunner) + captured = {} + + async def _run(path, user_settings, *args, **kwargs): + captured["kwargs"] = kwargs + return OBBject(results=[{"a": 1}]) + + runner.run = _run + + async def endpoint(standard_params=None, extra_params=None, **kwargs): + return OBBject(results=[{"a": 1}]) + + route = _make_route(endpoint) + wrapper = build_api_wrapper(runner, route) + + fake_user_settings = UserSettings() + fake_user_settings.defaults.commands = { # type: ignore[attr-defined] + "api.v1.x": {"symbol": "AAPL", "limit": 10} + } + await wrapper( + standard_params=_SP(), + extra_params=_EP(), + __authenticated_user_settings=fake_user_settings, + ) + + assert captured["kwargs"]["standard_params"]["symbol"] == "MSFT" + assert captured["kwargs"]["extra_params"]["limit"] == 5 + + +@pytest.mark.asyncio +async def test_wrapper_serialization_error_raises_openbb_error(): + """If JSONResponse serialization fails for an extension-modified output, OpenBBError is raised.""" + from openbb_core.app.model.abstract.error import OpenBBError + + runner = MagicMock(spec=CommandRunner) + + async def _run(path, user_settings, *args, **kwargs): + out = OBBject(results=[{"a": 1}]) + out._extension_modified = True # type: ignore[attr-defined] + return out + + runner.run = _run + + async def endpoint(**kwargs): + return OBBject(results=[{"a": 1}]) + + route = _make_route(endpoint) + wrapper = build_api_wrapper(runner, route) + with ( + patch( + "openbb_core.api.router.commands.jsonable_encoder", + side_effect=RuntimeError("boom"), + ), + pytest.raises(OpenBBError, match="Error serializing"), + ): + await wrapper() + + +def test_add_command_map_wraps_and_includes_plugin_routes(monkeypatch): + from fastapi import APIRouter + + async def endpoint(): + return OBBject(results=[{"a": 1}]) + + plugins_api_router = APIRouter() + plugins_api_router.add_api_route("/plugin", endpoint, methods=["GET"]) + plugins_router = type("PR", (), {"api_router": plugins_api_router})() + + async def wrapped(): + return OBBject(results=[{"a": 1}]) + + monkeypatch.setattr( + "openbb_core.api.router.commands.RouterLoader.from_extensions", + lambda: plugins_router, + ) + monkeypatch.setattr( + "openbb_core.api.router.commands.build_api_wrapper", + lambda command_runner, route: wrapped, + ) + + api_router = APIRouter() + add_command_map(MagicMock(spec=CommandRunner), api_router) + + assert plugins_api_router.routes[0].endpoint is wrapped + assert any(getattr(route, "path", None) == "/plugin" for route in api_router.routes) diff --git a/openbb_platform/core/tests/api/test_router/test_router_coverage.py b/openbb_platform/core/tests/api/test_router/test_router_coverage.py index 616e75fa42a..b2ec560b374 100644 --- a/openbb_platform/core/tests/api/test_router/test_router_coverage.py +++ b/openbb_platform/core/tests/api/test_router/test_router_coverage.py @@ -139,3 +139,67 @@ def test_get_commands_model_map_handles_missing_response_schema(): result = asyncio.run(get_commands_model_map(cmap, pi)) assert result["/eco/cpi"]["response_schema_name"] is None + + +def test_get_commands_model_map_serializes_data_annotations_and_merges_openbb_data(): + cmap = _seeded_command_map({}, {}, {"/x/y": "ModelX"}) + + q_field = MagicMock(_attributes_set={"annotation": str, "description": "q"}) + d_field = MagicMock(_attributes_set={"annotation": int, "description": "d"}) + shared_d_field = MagicMock( + _attributes_set={"annotation": float, "description": "shared"} + ) + + pi = MagicMock() + pi.map = { + "ModelX": { + "provider_a": { + "QueryParams": {"docstring": "query", "fields": {"q": q_field}}, + "Data": {"docstring": "data", "fields": {"d": d_field}}, + }, + "openbb": { + "QueryParams": {"docstring": "", "fields": {}}, + "Data": { + "docstring": "shared data", + "fields": {"shared_d": shared_d_field}, + }, + }, + } + } + pi.return_annotations = {"ModelX": None} + + result = asyncio.run(get_commands_model_map(cmap, pi)) + + entry = result["/x/y"]["provider_a"] + assert entry["Data"]["docstring"] == "data" + assert entry["Data"]["fields"]["d"]["annotation"] == str(int) + assert entry["Data"]["fields"]["shared_d"]["annotation"] == str(float) + assert "openbb" not in result["/x/y"] + + +def test_get_commands_model_map_serializer_handles_type_values(): + cmap = _seeded_command_map({}, {}, {"/x/z": "ModelZ"}) + + type_value_field = MagicMock( + _attributes_set={"annotation": None, "custom_type": int, "description": "z"} + ) + pi = MagicMock() + pi.map = { + "ModelZ": { + "provider_a": { + "QueryParams": {"docstring": "", "fields": {"z": type_value_field}}, + "Data": {"docstring": "", "fields": {}}, + }, + "openbb": { + "QueryParams": {"docstring": "", "fields": {}}, + "Data": {"docstring": "", "fields": {}}, + }, + } + } + pi.return_annotations = {"ModelZ": None} + + result = asyncio.run(get_commands_model_map(cmap, pi)) + + assert result["/x/z"]["provider_a"]["QueryParams"]["fields"]["z"][ + "custom_type" + ] == str(int) diff --git a/openbb_platform/core/tests/app/logs/formatters/test_formatter_with_exceptions.py b/openbb_platform/core/tests/app/logs/formatters/test_formatter_with_exceptions.py index ae6fb527e10..02e50383bde 100644 --- a/openbb_platform/core/tests/app/logs/formatters/test_formatter_with_exceptions.py +++ b/openbb_platform/core/tests/app/logs/formatters/test_formatter_with_exceptions.py @@ -14,7 +14,6 @@ logging_settings = Mock() logging_settings.app_name = "test_app_name" logging_settings.app_id = "test_app_id" logging_settings.session_id = "test_session_id" -logging_settings.user_id = "test_user_id" @pytest.fixture @@ -57,6 +56,34 @@ def test_level_name_with_levelname(formatter): assert formatter.calculate_level_name(record) == "W" +def test_level_name_without_exc_text_or_levelname(formatter): + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="", + lineno=0, + msg="", + args=None, + exc_info=None, + func=None, + ) + record.exc_text = None + record.levelname = "" + assert formatter.calculate_level_name(record) == "U" + + +def test_format_exception_returns_repr(formatter): + try: + raise RuntimeError("boom") + except RuntimeError: + import sys + + ei = sys.exc_info() + out = formatter.formatException(ei) + assert out.startswith("'") + assert "RuntimeError: boom" in out + + # Test when func_name_override and session_id are present in the record def test_extract_log_extra_with_override_and_session_id(formatter): """Test the extract_log_extra method.""" @@ -315,7 +342,7 @@ def test_format(formatter): formatted_log = formatter.format(log_record) # Assertions - expected_log = "INFO|test_app_name|unknown-commit|test_app_id|test_session_id|test_user_id|Filtered log message" + expected_log = "INFO|test_app_name|unknown-commit|test_app_id|test_session_id|Filtered log message" assert formatted_log == expected_log diff --git a/openbb_platform/core/tests/app/logs/handlers/test_path_tracking_file_handler.py b/openbb_platform/core/tests/app/logs/handlers/test_path_tracking_file_handler.py index 3a970eb7979..129b488812c 100644 --- a/openbb_platform/core/tests/app/logs/handlers/test_path_tracking_file_handler.py +++ b/openbb_platform/core/tests/app/logs/handlers/test_path_tracking_file_handler.py @@ -80,3 +80,16 @@ def test_clean_expired_files(handler): assert mock_get_expired_file_list.call_count == 3 assert mock_remove_file_list.call_count == 3 + + +def test_settings_setter(handler, mocked_path): + """Test settings.setter stores the settings value.""" + new_settings = MagicMock(spec=MockLoggingSettings) + new_settings.app_name = "new_app" + + # Set the settings + handler.settings = new_settings + + # Retrieve and verify + retrieved = handler.settings + assert retrieved.app_name == "new_app" diff --git a/openbb_platform/core/tests/app/logs/models/__init__.py b/openbb_platform/core/tests/app/logs/models/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openbb_platform/core/tests/app/logs/models/test_logging_settings.py b/openbb_platform/core/tests/app/logs/models/test_logging_settings.py new file mode 100644 index 00000000000..59c82ebb71f --- /dev/null +++ b/openbb_platform/core/tests/app/logs/models/test_logging_settings.py @@ -0,0 +1,38 @@ +"""Tests for openbb_core.app.logs.models.logging_settings.""" + +from pathlib import Path +from types import SimpleNamespace + +from openbb_core.app.logs.models.logging_settings import LoggingSettings +from openbb_core.app.model.system_settings import SystemSettings +from openbb_core.app.model.user_settings import UserSettings + + +def test_logging_settings_defaults(): + ls = LoggingSettings() + assert isinstance(ls.app_name, str) + assert isinstance(ls.app_id, str) and ls.app_id + assert isinstance(ls.session_id, str) and ls.session_id + assert isinstance(ls.user_logs_directory, Path) + + +def test_logging_settings_uses_provided_user_and_system_settings(tmp_path): + user = UserSettings() + user.preferences.data_directory = str(tmp_path) + system = SystemSettings() + ls = LoggingSettings(user_settings=user, system_settings=system) + assert str(tmp_path) in str(ls.user_logs_directory) + assert ls.app_name == system.logging_app_name + assert ls.frequency == system.logging_frequency + assert ls.handler_list == system.logging_handlers + assert ls.verbosity == system.logging_verbosity + assert ls.platform == system.platform + assert ls.python_version == system.python_version + assert ls.platform_version == system.version + + +def test_logging_settings_no_preferences_falls_back_to_home(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) # type: ignore[arg-type] + user = SimpleNamespace(preferences=None) + ls = LoggingSettings(user_settings=user) # type: ignore[arg-type] + assert "OpenBBUserData" in str(ls.user_logs_directory) diff --git a/openbb_platform/core/tests/app/logs/test_handlers_manager.py b/openbb_platform/core/tests/app/logs/test_handlers_manager.py index 8ddcc361d21..3647f4909ca 100644 --- a/openbb_platform/core/tests/app/logs/test_handlers_manager.py +++ b/openbb_platform/core/tests/app/logs/test_handlers_manager.py @@ -79,11 +79,15 @@ def test_update_handlers(): ): settings = Mock() settings.handler_list = ["file"] + settings.logging_suppress = False + settings.verbosity = 20 settings.any_other_attr = "mock_settings" logger = logging.getLogger("test_update_handlers") handlers_manager = HandlersManager(logger=logger, settings=settings) + handlers_manager.setup() changed_settings = Mock() + changed_settings.logging_suppress = False changed_settings.any_other_attr = "changed_settings" handlers_manager.update_handlers(settings=changed_settings) @@ -92,3 +96,18 @@ def test_update_handlers(): if isinstance(hdlr, MockPathTrackingFileHandler): assert hdlr.settings == changed_settings assert hdlr.formatter.settings == changed_settings # type: ignore[union-attr] + + +def test_setup_unknown_handler_logs_debug(): + settings = Mock() + settings.verbosity = 20 + settings.handler_list = ["invalid"] + settings.logging_suppress = False + logger = logging.getLogger("test_setup_unknown_handler_logs_debug") + logger.handlers = [] + logger.debug = Mock() + + handlers_manager = HandlersManager(logger=logger, settings=settings) + handlers_manager.setup() + + logger.debug.assert_called_once_with("Unknown log handler.") 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 e56865e4c21..bc27d459e2d 100644 --- a/openbb_platform/core/tests/app/logs/test_logging_service.py +++ b/openbb_platform/core/tests/app/logs/test_logging_service.py @@ -107,6 +107,37 @@ def test_correctly_initialized(): mock_log_startup.assert_called_once() +def test_init_with_logging_suppress_returns_early(): + from openbb_core.app.model.abstract.singleton import SingletonMeta + + SingletonMeta._instances.pop(LoggingService, None) + + class _Suppressed: + logging_suppress = True + + svc = LoggingService(system_settings=_Suppressed(), user_settings=Mock()) + assert not hasattr(svc, "_logging_settings") + + +def test_setup_handlers_executes_and_logs(): + svc = LoggingService.__new__(LoggingService) + svc._logger = MagicMock() + svc._logging_settings = Mock(handler_list=["stdout"], verbosity=2) + + fake_manager = Mock() + fake_manager.setup = Mock() + + with patch( + "openbb_core.app.logs.logging_service.HandlersManager", + return_value=fake_manager, + ): + out = LoggingService._setup_handlers(svc) + + assert out is fake_manager + fake_manager.setup.assert_called_once() + assert svc._logger.info.call_count == 4 + + def test_logging_settings_setter(logging_service): """Test the logging_settings setter.""" custom_user_settings = Mock() diff --git a/openbb_platform/core/tests/app/logs/utils/test_utils_create_helpers.py b/openbb_platform/core/tests/app/logs/utils/test_utils_create_helpers.py new file mode 100644 index 00000000000..e5b0025334d --- /dev/null +++ b/openbb_platform/core/tests/app/logs/utils/test_utils_create_helpers.py @@ -0,0 +1,41 @@ +"""Additional tests for openbb_core.app.logs.utils.utils covering create_* helpers.""" + +from pathlib import Path + +from openbb_core.app.logs.utils.utils import ( + create_log_dir_if_not_exists, + create_log_uuid_if_not_exists, + create_uuid_dir_if_not_exists, + get_log_id, +) + + +def test_create_log_dir_if_not_exists_creates_and_is_idempotent(tmp_path): + log_dir = create_log_dir_if_not_exists(str(tmp_path)) + assert log_dir.is_dir() + assert log_dir == (tmp_path / "logs").absolute() + again = create_log_dir_if_not_exists(str(tmp_path)) + assert again == log_dir + + +def test_create_log_uuid_writes_then_reads(tmp_path): + log_dir = tmp_path / "logs" + log_dir.mkdir() + first = create_log_uuid_if_not_exists(log_dir) + second = create_log_uuid_if_not_exists(log_dir) + assert first == second + assert get_log_id(log_dir).read_text(encoding="utf-8").rstrip() == first + + +def test_create_uuid_dir_if_not_exists(tmp_path): + log_dir = tmp_path / "logs" + log_dir.mkdir() + out = create_uuid_dir_if_not_exists(log_dir, "abc-123") + assert out.is_dir() + assert out.name == "abc-123" + + +def test_get_log_id_returns_logid_path(tmp_path): + p = get_log_id(tmp_path) + assert isinstance(p, Path) + assert p.name == ".logid" diff --git a/openbb_platform/core/tests/app/model/charts/test_chart.py b/openbb_platform/core/tests/app/model/charts/test_chart.py index 4cf09c0a258..77abda15203 100644 --- a/openbb_platform/core/tests/app/model/charts/test_chart.py +++ b/openbb_platform/core/tests/app/model/charts/test_chart.py @@ -57,3 +57,12 @@ def test_charting_config_validation(): def test_show(): """Test the show method.""" # TODO : add test after the function is properly refactored + + +def test_chart_repr_contains_fields(): + chart = Chart(content={"k": 1}, format="plotly") + + rep = repr(chart) + assert rep.startswith("Chart") + assert "content: {'k': 1}" in rep + assert "format: plotly" in rep diff --git a/openbb_platform/core/tests/app/model/test_api_settings.py b/openbb_platform/core/tests/app/model/test_api_settings.py new file mode 100644 index 00000000000..a08d8038742 --- /dev/null +++ b/openbb_platform/core/tests/app/model/test_api_settings.py @@ -0,0 +1,20 @@ +"""Test APISettings model.""" + +from openbb_core.app.model.api_settings import APISettings + + +def test_api_settings_prefix(): + """Test prefix property returns formatted version string.""" + settings = APISettings(version="2") + assert settings.prefix == "/api/v2" + + settings_v1 = APISettings(version="1") + assert settings_v1.prefix == "/api/v1" + + +def test_api_settings_repr(): + """Test __repr__ returns formatted string with class name and fields.""" + settings = APISettings(version="1") + result = repr(settings) + assert "APISettings" in result + assert "version" in result diff --git a/openbb_platform/core/tests/app/model/test_credentials.py b/openbb_platform/core/tests/app/model/test_credentials.py index a342db19553..72b1b0f0019 100644 --- a/openbb_platform/core/tests/app/model/test_credentials.py +++ b/openbb_platform/core/tests/app/model/test_credentials.py @@ -5,6 +5,8 @@ import json import sys from unittest.mock import mock_open, patch +from pydantic import SecretStr + def test_credentials(): """Test the Credentials model.""" @@ -78,3 +80,39 @@ def test_credentials_env_overrides_null(): creds = Credentials() assert creds.econdb_api_key.get_secret_value() == "env_econdb_key" + + +def test_credentials_model_post_init_uses_env_defaults_and_show_and_update(): + for mod_name in list(sys.modules.keys()): + if "openbb_core.app.model.credentials" in mod_name: + del sys.modules[mod_name] + + with ( + patch( + "openbb_core.app.provider_interface.ProviderInterface" + ) as mock_provider_interface, + patch("openbb_core.app.extension_loader.ExtensionLoader") as mock_loader, + patch("openbb_core.app.model.credentials.Path.exists", return_value=False), + patch.dict("os.environ", {}, clear=True), + ): + mock_provider_interface.return_value.credentials = {"test": ["test_api_key"]} + mock_loader.return_value.obbject_objects = {} + + import openbb_core.app.model.credentials as credentials_module + + importlib.reload(credentials_module) + Credentials = credentials_module.Credentials + + creds = Credentials(test_api_key="") + Credentials._env_defaults = {"test_api_key": SecretStr("from_env")} + creds.model_post_init(None) + assert creds.test_api_key.get_secret_value() == "from_env" + + with patch("builtins.print") as print_mock: + creds.show() + printed = "\n".join(str(x) for x in print_mock.call_args[0]) + assert "test_api_key: from_env" in printed + + incoming = Credentials(test_api_key="updated") + creds.update(incoming) + assert creds.test_api_key.get_secret_value() == "updated" diff --git a/openbb_platform/core/tests/app/model/test_credentials_helpers.py b/openbb_platform/core/tests/app/model/test_credentials_helpers.py new file mode 100644 index 00000000000..3aa829c9678 --- /dev/null +++ b/openbb_platform/core/tests/app/model/test_credentials_helpers.py @@ -0,0 +1,283 @@ +"""Targeted unit tests for ``openbb_core.app.model.credentials`` helpers.""" + +from unittest.mock import patch + +import pytest + +from openbb_core.app.model.credentials import ( + Credentials, + CredentialsLoader, +) + + +def test_normalize_credential_map_empty_input_returns_empty(): + assert CredentialsLoader._normalize_credential_map({}) == {} + assert CredentialsLoader._normalize_credential_map(None) == {} + + +def test_normalize_credential_map_lowercases_keys_and_strips(): + out = CredentialsLoader._normalize_credential_map({" ABC ": "v"}) + assert "abc" in out + assert out["abc"] == "v" + + +def test_normalize_credential_map_keeps_non_string_keys_untouched(): + out = CredentialsLoader._normalize_credential_map({1: "v"}) + assert 1 in out + + +def test_normalize_credential_map_skips_empty_override(): + """Empty value does NOT overwrite an existing key.""" + out = CredentialsLoader._normalize_credential_map({"abc": "real", "ABC": ""}) + assert out["abc"] == "real" + + +def test_format_credentials_appends_additional_unknown_keys(): + loader = CredentialsLoader() + loader.credentials = {"prov": ["prov_api_key"]} + out = loader.format_credentials({"extra_random_key": "v"}) + assert "prov_api_key" in out + assert "extra_random_key" in out + + +def test_format_credentials_warns_when_duplicate_credential(): + loader = CredentialsLoader() + loader.credentials = {"prov_a": ["shared"], "prov_b": ["shared"]} + with pytest.warns(Warning): + out = loader.format_credentials({}) + assert "shared" in out + + +def test_format_credentials_skips_additional_key_already_formatted(): + class _StickyDict(dict): + def pop(self, key, default=None): + return self.get(key, default) + + loader = CredentialsLoader() + loader.credentials = {"prov": ["api_key"]} + + with patch("builtins.dict", _StickyDict): + out = loader.format_credentials({"api_key": "from_additional"}) + + assert "api_key" in out + + +def test_from_obbject_extension_error_warns_when_not_debug(): + import warnings as _warnings + + import openbb_core.app.model.credentials as _cred_mod + + loader = _cred_mod.CredentialsLoader() + loader.credentials = {} + + class _BoomExt: + @property + def credentials(self): + raise RuntimeError("boom") + + fake_loader = type("FakeLoader", (), {"obbject_objects": {"badext": _BoomExt()}}) + + with ( + patch("openbb_core.app.model.credentials.ExtensionLoader", lambda: fake_loader), + patch( + "openbb_core.app.model.credentials.Env", + type("E", (), {"DEBUG_MODE": False}), + ), + _warnings.catch_warnings(record=True) as caught, + ): + _warnings.simplefilter("always") + loader.from_obbject() + assert any("badext" in str(w.message) for w in caught) + + +def test_from_obbject_skips_existing_extension_name(): + import warnings as _warnings + + import openbb_core.app.model.credentials as _cred_mod + + loader = _cred_mod.CredentialsLoader() + loader.credentials = {"already": ["x"]} + + class _Ext: + credentials = ["new"] + + fake_loader = type("FakeLoader", (), {"obbject_objects": {"already": _Ext()}}) + with ( + patch("openbb_core.app.model.credentials.ExtensionLoader", lambda: fake_loader), + _warnings.catch_warnings(record=True) as caught, + ): + _warnings.simplefilter("always") + loader.from_obbject() + assert any("already" in str(w.message) for w in caught) + assert loader.credentials["already"] == ["x"] + + +def test_is_unset_none(): + assert Credentials._is_unset(None) is True + + +def test_is_unset_empty_secret_str(): + from pydantic import SecretStr + + assert Credentials._is_unset(SecretStr("")) is True + + +def test_is_unset_filled_secret_str(): + from pydantic import SecretStr + + assert Credentials._is_unset(SecretStr("xx")) is False + + +def test_is_unset_empty_string(): + assert Credentials._is_unset("") is True + + +def test_is_unset_other_value_is_set(): + assert Credentials._is_unset(123) is False + + +def test_credentials_repr_contains_class_name_and_keys(): + c = Credentials() + r = repr(c) + assert "Credentials" in r + + +def test_credentials_update_merges_non_none_values(): + """``update`` copies fields from one Credentials into another.""" + from pydantic import SecretStr + + a = Credentials() + b = Credentials() + # Pick the first known credential field and set it on b + field_name = next(iter(type(b).model_fields), None) + if field_name is None: + pytest.skip("No credential fields loaded in this environment") + setattr(b, field_name, SecretStr("FROM_B")) + a.update(b) + val = getattr(a, field_name) + assert val is not None + assert val.get_secret_value() == "FROM_B" + + +def test_from_obbject_debug_mode_raises_loading_error(): + import openbb_core.app.model.credentials as _cred_mod + + loader = _cred_mod.CredentialsLoader() + loader.credentials = {} + + class _BoomExt: + @property + def credentials(self): + raise RuntimeError("boom") + + fake_loader = type("FakeLoader", (), {"obbject_objects": {"badext": _BoomExt()}}) + + with ( + patch("openbb_core.app.model.credentials.ExtensionLoader", lambda: fake_loader), + patch( + "openbb_core.app.model.credentials.Env", + type("E", (), {"DEBUG_MODE": True}), + ), + pytest.raises(_cred_mod.LoadingError, match="badext"), + ): + loader.from_obbject() + + +def test_model_post_init_applies_env_defaults_when_unset(): + """When a credential is unset, the env default value is applied.""" + from pydantic import SecretStr + + # Find a field that is unset by default (no env value loaded for it) + instance = Credentials() + field_name = next( + ( + f + for f in Credentials.model_fields + if Credentials._is_unset(getattr(instance, f, None)) + ), + None, + ) + if field_name is None: + pytest.skip("Every credential field has a real env value loaded") + + env_defaults_backup = dict(Credentials._env_defaults) + Credentials._env_defaults[field_name] = SecretStr("FROM_ENV") + try: + c = Credentials() + val = getattr(c, field_name) + assert val is not None + secret = val.get_secret_value() if hasattr(val, "get_secret_value") else val + assert secret == "FROM_ENV" # noqa: S105 + finally: + Credentials._env_defaults.clear() + Credentials._env_defaults.update(env_defaults_backup) + + +def test_model_post_init_skips_already_set_field(): + from pydantic import SecretStr + + field_name = next(iter(Credentials.model_fields), None) + if field_name is None: + pytest.skip("No credential fields loaded in this environment") + env_defaults_backup = dict(Credentials._env_defaults) + Credentials._env_defaults[field_name] = SecretStr("FROM_ENV") + try: + c = Credentials(**{field_name: SecretStr("USER_SET")}) + val = getattr(c, field_name) + secret = val.get_secret_value() if hasattr(val, "get_secret_value") else val + assert secret == "USER_SET" # noqa: S105 + finally: + Credentials._env_defaults.clear() + Credentials._env_defaults.update(env_defaults_backup) + + +def test_model_post_init_skips_env_default_for_unknown_field(): + env_defaults_backup = dict(Credentials._env_defaults) + Credentials._env_defaults["not_a_model_field"] = "X" + try: + c = Credentials() + assert not hasattr(c, "not_a_model_field") + finally: + Credentials._env_defaults.clear() + Credentials._env_defaults.update(env_defaults_backup) + + +def test_credentials_show_prints_class_name(capsys): + """``show()`` prints class name and dumped credentials.""" + Credentials().show() + out = capsys.readouterr().out + assert "Credentials" in out + + +def test_load_picks_up_env_credentials(monkeypatch, tmp_path): + """``load()`` should pull a credential from the environment when the env key matches an existing provider field.""" + import openbb_core.app.model.credentials as _cred_mod + + field_name = next(iter(Credentials.model_fields), None) + if field_name is None: + pytest.skip("No credential fields loaded in this environment") + + monkeypatch.setenv(field_name.upper(), "ENV_VALUE") + monkeypatch.setattr(_cred_mod, "USER_SETTINGS_PATH", str(tmp_path / "missing.json")) + + model_cls = _cred_mod.CredentialsLoader().load() + instance = model_cls() + val = getattr(instance, field_name) + assert val is not None + + +def test_load_reads_credentials_from_user_settings_file(monkeypatch, tmp_path): + """A credentials block in the user settings file is merged in.""" + import openbb_core.app.model.credentials as _cred_mod + + field_name = next(iter(Credentials.model_fields), None) + if field_name is None: + pytest.skip("No credential fields loaded in this environment") + settings_file = tmp_path / "user_settings.json" + settings_file.write_text('{"credentials": {"' + field_name + '": "FROM_FILE"}}') + monkeypatch.setattr(_cred_mod, "USER_SETTINGS_PATH", str(settings_file)) + + model_cls = _cred_mod.CredentialsLoader().load() + instance = model_cls() + val = getattr(instance, field_name) + assert val is not None diff --git a/openbb_platform/core/tests/app/model/test_defaults.py b/openbb_platform/core/tests/app/model/test_defaults.py index a64e318d5dc..5d211845154 100644 --- a/openbb_platform/core/tests/app/model/test_defaults.py +++ b/openbb_platform/core/tests/app/model/test_defaults.py @@ -13,3 +13,44 @@ def test_defaults(): def test_fields(): """Test the Defaults fields.""" assert "commands" in Defaults.model_fields + + +"""Additional tests for openbb_core.app.model.defaults.Defaults.""" + +import pytest + +from openbb_core.app.model.abstract.warning import OpenBBWarning + + +def test_defaults_normalizes_route_keys_and_provider_string(): + d = Defaults(commands={"/equity/profile": {"provider": "fmp"}}) + assert "equity.profile" in d.commands + assert d.commands["equity.profile"]["provider"] == ["fmp"] + + +def test_defaults_routes_alias_warns(): + with pytest.warns(OpenBBWarning, match="deprecated"): + d = Defaults( + routes={"/x/y": {"provider": "p"}}, + preferences={"show_warnings": False}, + ) + assert "x.y" in d.commands + + +def test_defaults_empty_routes_treated_as_missing(): + d = Defaults(routes={}, preferences={"show_warnings": True}) + assert d.commands == {} + + +def test_defaults_update_merges_commands(): + d = Defaults(commands={"a.b": {"provider": ["p1"]}}) + d.update(Defaults(commands={"a.c": {"provider": ["p2"]}})) + assert "a.b" in d.commands + assert "a.c" in d.commands + + +def test_defaults_repr_contains_classname(): + d = Defaults(commands={"a.b": {"provider": ["p"]}}) + r = repr(d) + assert "Defaults" in r + assert "commands" in r diff --git a/openbb_platform/core/tests/app/model/test_example.py b/openbb_platform/core/tests/app/model/test_example.py new file mode 100644 index 00000000000..8aae4e8455f --- /dev/null +++ b/openbb_platform/core/tests/app/model/test_example.py @@ -0,0 +1,152 @@ +"""Tests for openbb_core.app.model.example.""" + +import pytest + +from openbb_core.app.model.example import ( + APIEx, + PythonEx, + filter_list, +) + + +def test_apiex_basic_to_python(): + eg = APIEx(parameters={"symbol": "AAPL"}) + out = eg.to_python(func_path=".equity.profile") + assert out == "obb.equity.profile(symbol='AAPL')\n" + + +def test_apiex_provider_kept_in_parameters_and_exposed(): + eg = APIEx(parameters={"symbol": "AAPL", "provider": "fmp"}) + assert eg.parameters["provider"] == "fmp" + assert eg.provider == "fmp" + + +def test_apiex_provider_rendered_in_to_python(): + eg = APIEx(parameters={"symbol": "AAPL", "provider": "fmp"}) + out = eg.to_python(func_path=".equity.profile") + assert "symbol='AAPL'" in out + assert "provider='fmp'" in out + + +def test_apiex_no_provider_means_none(): + eg = APIEx(parameters={"symbol": "AAPL"}) + assert eg.provider is None + + +def test_apiex_provider_must_be_string(): + with pytest.raises(ValueError, match="Provider must be a string"): + APIEx(parameters={"provider": 123}) + + +def test_apiex_description_required_when_many_real_params(): + with pytest.raises(ValueError, match="Description is required"): + APIEx(parameters={"a": 1, "b": 2, "c": 3, "d": 4}) + + +def test_apiex_description_threshold_ignores_provider(): + eg = APIEx(parameters={"a": 1, "b": 2, "c": 3, "provider": "fmp"}) + assert eg.provider == "fmp" + + +def test_apiex_to_python_renders_description_and_param_types(): + from datetime import date + + eg = APIEx( + description="Fetch a quote.", + parameters={"symbol": "AAPL", "limit": 5, "as_of": "2023-01-01"}, + ) + out = eg.to_python( + func_path=".equity.quote", + param_types={"symbol": str, "limit": int, "as_of": date}, + indentation=" ", + prompt=">>> ", + ) + assert " >>> # Fetch a quote.\n" in out + assert "symbol='AAPL'" in out + assert "limit=5" in out + assert "as_of='2023-01-01'" in out + assert out.endswith(")\n") + + +def test_apiex_to_python_quotes_unknown_string_param(): + eg = APIEx(parameters={"foo": "bar"}) + out = eg.to_python(func_path=".x") + assert "foo='bar'" in out + + +def test_apiex_to_python_non_string_param_without_type_map(): + eg = APIEx(parameters={"limit": 5}) + out = eg.to_python(func_path=".x") + assert "limit=5" in out + + +def test_apiex_unpack_type_handles_unions_and_generics(): + assert int in APIEx._unpack_type(int) + assert str in APIEx._unpack_type(str | int) + assert str in APIEx._unpack_type(list[str]) + + +def test_apiex_mock_data_timeseries_default(): + rows = APIEx.mock_data("timeseries", size=3) + assert len(rows) == 3 + assert {"date", "open", "high", "low", "close", "volume"} <= set(rows[0].keys()) + + +def test_apiex_mock_data_timeseries_custom_sample(): + rows = APIEx.mock_data( + "timeseries", size=2, sample={"date": "2024-01-01", "value": 100.0} + ) + assert len(rows) == 2 + assert "value" in rows[0] + assert rows[0]["date"] != rows[1]["date"] + + +def test_apiex_mock_data_panel_default(): + rows = APIEx.mock_data("panel", size=2) + assert all("portfolio_value" in r for r in rows) + assert any(r.get("is_multiindex") for r in rows) + + +def test_apiex_mock_data_panel_custom_string_sample(): + rows = APIEx.mock_data("panel", size=1, sample={"label": "L"}) + assert len(rows) == 2 + assert rows[0]["label"] == "L_0" + assert rows[1]["label"] == "L_1" + + +def test_apiex_mock_data_invalid_dataset(): + with pytest.raises(ValueError, match="not found"): + APIEx.mock_data("nope") # type: ignore[arg-type] + + +def test_pythonex_to_python_renders_lines(): + eg = PythonEx( + description="Compute returns.", + code=["import pandas as pd", "df = pd.DataFrame()"], + ) + out = eg.to_python(indentation=" ", prompt=">>> ") + assert " >>> # Compute returns.\n" in out + assert " >>> import pandas as pd\n" in out + assert " >>> df = pd.DataFrame()\n" in out + + +def test_filter_list_keeps_matching_provider_and_drops_others(): + e_fmp = APIEx(parameters={"symbol": "AAPL", "provider": "fmp"}) + e_polygon = APIEx(parameters={"symbol": "MSFT", "provider": "polygon"}) + e_no_provider = APIEx(parameters={"symbol": "GOOG"}) + py = PythonEx(description="d", code=["x = 1"]) + + out = filter_list([e_fmp, e_polygon, e_no_provider, py], providers=["fmp"]) + + assert e_fmp in out + assert e_polygon not in out + assert e_no_provider in out + assert py in out + + +def test_filter_list_empty_providers_drops_provider_specific(): + e_fmp = APIEx(parameters={"symbol": "AAPL", "provider": "fmp"}) + e_no_provider = APIEx(parameters={"symbol": "GOOG"}) + out = filter_list([e_fmp, e_no_provider], providers=[]) + assert e_fmp not in out + assert e_no_provider in out diff --git a/openbb_platform/core/tests/app/model/test_extension.py b/openbb_platform/core/tests/app/model/test_extension.py index 917392f3d80..5c52e5d1db5 100644 --- a/openbb_platform/core/tests/app/model/test_extension.py +++ b/openbb_platform/core/tests/app/model/test_extension.py @@ -122,17 +122,12 @@ def test_obbject_accessor_registers_on_mocked_obbject_module(monkeypatch): monkeypatch.delitem(sys.modules, mod_name) -# ----------------- SystemSettings security-gate enforcement ----------------- -# # The Extension constructor enforces two opt-in flags that must be set in # system_settings.json: # * `allow_on_command_output` — must be True to load *any* extension that # hooks `on_command_output`. # * `allow_mutable_extensions` — must be additionally True to load an # extension that mutates the OBBject (`immutable=False`). -# -# These tests drive both gates by patching ``SystemService`` to return -# settings with the desired flags, exactly as ``test_command_runner.py`` does. def _patch_system_settings( @@ -246,9 +241,6 @@ def test_command_output_paths_requires_on_command_output(monkeypatch): ] -# --------------- ExtensionLoader callback registration coverage -------------- - - def test_extension_loader_registers_command_output_callbacks_by_path(monkeypatch): """`ExtensionLoader._register_command_output_callbacks` indexes extensions by path. diff --git a/openbb_platform/core/tests/app/model/test_field.py b/openbb_platform/core/tests/app/model/test_field.py new file mode 100644 index 00000000000..24efe8157b4 --- /dev/null +++ b/openbb_platform/core/tests/app/model/test_field.py @@ -0,0 +1,28 @@ +"""Tests for openbb_core.app.model.field.OpenBBField.""" + +from openbb_core.app.model.field import OpenBBField + + +def test_openbbfield_repr_without_choices(): + f = OpenBBField(description="hello") + r = repr(f) + assert "OpenBBField" in r + assert "hello" in r + assert "choices" not in r + + +def test_openbbfield_repr_with_choices(): + f = OpenBBField(description="hello", choices=["a", "b"]) + r = repr(f) + assert "choices" in r + assert "a" in r and "b" in r + + +def test_openbbfield_choices_property_returns_list(): + f = OpenBBField(description="x", choices=[1, 2]) + assert f.choices == [1, 2] + + +def test_openbbfield_choices_property_returns_none_when_unset(): + f = OpenBBField(description="x") + assert f.choices is None diff --git a/openbb_platform/core/tests/app/model/test_metadata.py b/openbb_platform/core/tests/app/model/test_metadata.py index e84bcdc38b3..fb9540b579a 100644 --- a/openbb_platform/core/tests/app/model/test_metadata.py +++ b/openbb_platform/core/tests/app/model/test_metadata.py @@ -38,6 +38,36 @@ def test_fields(): assert "timestamp" in fields +def test_metadata_repr(): + """Test __repr__ returns formatted string.""" + m = Metadata( + arguments={"provider_choices": {}, "standard_params": {}, "extra_params": {}}, + route="/test", + timestamp=datetime.now(), + duration=0, + ) + result = repr(m) + assert "Metadata" in result + assert "route" in result + + +def test_scale_arguments_series(): + """Test Series branch in scale_arguments.""" + series = pd.Series([1, 2, 3], name="price") + m = Metadata( + arguments={ + "provider_choices": {}, + "standard_params": {}, + "extra_params": {"price_series": series}, + }, + route="/test", + timestamp=datetime.now(), + duration=0, + ) + assert m.arguments["extra_params"]["price_series"]["type"] == "Series" + assert "price" in m.arguments["extra_params"]["price_series"]["columns"] + + @pytest.mark.parametrize( "input_data, expected_output", [ diff --git a/openbb_platform/core/tests/app/model/test_obbject.py b/openbb_platform/core/tests/app/model/test_obbject.py index da0a15770fe..d1d5cf661a5 100644 --- a/openbb_platform/core/tests/app/model/test_obbject.py +++ b/openbb_platform/core/tests/app/model/test_obbject.py @@ -394,3 +394,81 @@ def test_show_chart_no_fig(): # Act and Assert with pytest.raises(OpenBBError, match="Chart not found."): mock_instance.show() + + # Use an object type that triggers TypeError inside pandas conversion path + class _Bad: + def __iter__(self): + raise TypeError("type bad") + + def __len__(self): + return 1 + + co: OBBject = OBBject(results=_Bad()) + with pytest.raises(OpenBBError, match="TypeError"): + co.to_dataframe(index=None) + + +def test_to_dict_list_orient_removes_index_key(): + co: OBBject = OBBject(results=[{"a": 1}]) + + with pytest.MonkeyPatch.context() as m: + m.setattr( + OBBject, + "to_dataframe", + lambda self, index=None: pd.DataFrame({"index": [1], "a": [2]}), + ) + out = co.to_dict(orient="list") + + assert "index" not in out + + +def test_to_df_alias_calls_to_dataframe(): + co: OBBject = OBBject(results=[{"a": [1]}]) + out = co.to_df(index=None) + assert isinstance(out, pd.DataFrame) + + +def test_to_dataframe_dict_double_valueerror_falls_back_to_series(monkeypatch): + calls = {"n": 0} + original = pd.DataFrame.from_dict + + def _boom(*args, **kwargs): + calls["n"] += 1 + if calls["n"] <= 2: + raise ValueError("fail") + return original(*args, **kwargs) + + with pytest.MonkeyPatch.context() as m: + m.setattr(pd.DataFrame, "from_dict", _boom) + co: OBBject = OBBject(results={"a": 1}) + out = co.to_dataframe(index=None) + assert list(out.columns) == ["index", "values"] + + +def test_to_dataframe_wraps_valueerror_in_openbb_error(monkeypatch): + def _raise_value_error(*args, **kwargs): + raise ValueError("boom") + + with pytest.MonkeyPatch.context() as m: + m.setattr(pd.DataFrame, "sort_index", _raise_value_error) + co: OBBject = OBBject(results=[{"a": [1]}]) + with pytest.raises(OpenBBError, match="ValueError: boom"): + co.to_dataframe(index=None) + + +def test_to_polars_uses_polars_from_pandas(monkeypatch): + class _P: + @staticmethod + def from_pandas(df): + return {"rows": len(df)} + + with pytest.MonkeyPatch.context() as m: + m.setattr("openbb_core.app.utils_optional.require_optional", lambda name: _P) + m.setattr( + OBBject, + "to_dataframe", + lambda self, index=None: pd.DataFrame({"a": [1], "b": [2]}), + ) + co: OBBject = OBBject(results=[{"a": [1], "b": [2]}]) + out = co.to_polars() + assert out == {"rows": 1} diff --git a/openbb_platform/core/tests/app/model/test_obbject_helpers.py b/openbb_platform/core/tests/app/model/test_obbject_helpers.py new file mode 100644 index 00000000000..2f2e863a015 --- /dev/null +++ b/openbb_platform/core/tests/app/model/test_obbject_helpers.py @@ -0,0 +1,263 @@ +"""Targeted tests for ``OBBject`` conversion edge branches.""" + +import pytest + +pd = pytest.importorskip("pandas") +np = pytest.importorskip("numpy") + +pytestmark = pytest.mark.requires_pandas + +from pydantic import BaseModel + +from openbb_core.app.model.abstract.error import OpenBBError +from openbb_core.app.model.obbject import OBBject + + +class _ScalarModel(BaseModel): + name: str = "x" + value: int = 1 + + +def test_to_dataframe_basemodel_scalar_uses_series_fallback(): + """A BaseModel whose dict is not list-of-list goes through Series.to_frame.""" + obb = OBBject(results=_ScalarModel()) + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + + +def test_to_dataframe_dict_of_dicts_uses_from_dict_T(): + obb = OBBject(results={"a": {"x": 1, "y": 2}, "b": {"x": 3, "y": 4}}) + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + + +def test_to_dataframe_dict_of_scalars_falls_back_to_series(): + """Dict of scalars triggers two ValueError fallbacks ending in Series.""" + obb = OBBject(results={"a": 1, "b": 2, "c": 3}) + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + + +def test_to_dataframe_results_is_dataframe_returns_passthrough(): + src = pd.DataFrame({"x": [1, 2]}) + obb = OBBject(results=src) + out = obb.to_dataframe() + assert out is src + + +def test_to_dataframe_none_results_raises(): + obb = OBBject(results=None) + with pytest.raises(OpenBBError, match="Results not found"): + obb.to_dataframe(index=None) + + +def test_to_dataframe_empty_results_raises(): + obb = OBBject(results=[]) + with pytest.raises(OpenBBError, match="Results not found"): + obb.to_dataframe(index=None) + + +def test_to_dataframe_str_results(): + obb = OBBject(results="hello") + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + assert df.iloc[0, 0] == "hello" + + +def test_to_dataframe_list_of_lists(): + obb = OBBject(results=[[1, 2], [3, 4]]) + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + assert df.shape == (2, 2) + + +def test_to_dataframe_list_of_one_dict_with_list_values(): + obb = OBBject(results=[{"a": [1, 2, 3], "b": [4, 5, 6]}]) + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + + +def test_to_dataframe_value_error_wraps_to_openbb_error(): + """A list of mixed dicts with conflicting keys triggers ValueError → wrapped.""" + obb = OBBject(results=[1, "two", 3.0]) + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + + +def test_to_dataframe_typeerror_wraps_to_openbb_error(): + obb = OBBject(results=object()) + with pytest.raises(OpenBBError): + obb.to_dataframe(index=None) + + +def test_to_polars_uses_pandas_dataframe(): + polars = pytest.importorskip("polars") + obb = OBBject(results=[{"a": 1, "b": 2}, {"a": 3, "b": 4}]) + out = obb.to_polars() + assert isinstance(out, polars.DataFrame) + + +def test_to_numpy_returns_ndarray(): + obb = OBBject(results=[{"a": 1, "b": 2}, {"a": 3, "b": 4}]) + arr = obb.to_numpy() + assert isinstance(arr, np.ndarray) + + +def test_to_dict_orient_list_with_dict_of_dicts_transposes(): + """Dict-of-dict + orient='list' goes through the .T branch and removes 'index'.""" + obb = OBBject(results={"a": {"x": 1, "y": 2}, "b": {"x": 3, "y": 4}}) + out = obb.to_dict(orient="list") + assert isinstance(out, dict) + assert "index" not in out + + +def test_to_dict_orient_records(): + obb = OBBject(results=[{"a": 1, "b": 2}, {"a": 3, "b": 4}]) + out = obb.to_dict(orient="records") + assert isinstance(out, list) + + +def test_repr_includes_class_name(): + obb = OBBject(results=[{"a": 1}]) + r = repr(obb) + assert "OBBject" in r + + +class _ListFieldModel(BaseModel): + """A model whose serialized dict has all list values.""" + + xs: list = [1, 2, 3] + ys: list = [4, 5, 6] + + +def test_to_dataframe_basemodel_with_dict_of_lists(): + obb = OBBject(results=_ListFieldModel(xs=[1, 2, 3], ys=[4, 5, 6])) + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + assert set(df.columns) == {"xs", "ys"} + + +def test_to_dataframe_dict_with_index_orient_fallback(): + """Force the from_dict(...).T to fail and fall through to orient='index'.""" + obb = OBBject(results={"row1": [1, 2], "row2": [3, 4, 5]}) + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + + +def test_to_dataframe_list_of_basemodels_all_array_properties(): + """A single Data with all array-typed fields uses model_dump directly.""" + from openbb_core.provider.abstract.data import Data + + obb = OBBject(results=[Data(arr=[1, 2, 3], other=[4, 5, 6])]) + df = obb.to_dataframe(index=None) + assert isinstance(df, pd.DataFrame) + + +def test_to_dataframe_with_sort_by(): + """``sort_by`` triggers the inplace ``sort_values`` branch.""" + obb = OBBject(results=[{"a": 3}, {"a": 1}, {"a": 2}]) + df = obb.to_dataframe(index=None, sort_by="a", ascending=True) + assert list(df["a"]) == [1, 2, 3] + + +def test_to_dataframe_with_sort_by_descending(): + obb = OBBject(results=[{"a": 1}, {"a": 3}, {"a": 2}]) + df = obb.to_dataframe(index=None, sort_by="a", ascending=False) + assert list(df["a"]) == [3, 2, 1] + + +def test_to_dataframe_unexpected_exception_wrapped(monkeypatch): + """A non-ValueError/non-TypeError exception is wrapped as 'unexpected error'.""" + from openbb_core.app import utils as utils_module + from openbb_core.provider.abstract.data import Data + + def boom(*_a, **_kw): + raise RuntimeError("boom") + + monkeypatch.setattr(utils_module, "basemodel_to_df", boom) + + obb = OBBject(results=[Data(a=1), Data(a=2)]) + with pytest.raises(OpenBBError, match="unexpected error"): + obb.to_dataframe(index=None) + + +def test_to_dataframe_index_set_to_existing_column(): + obb = OBBject(results=[{"a": 1, "b": 10}, {"a": 2, "b": 20}]) + df = obb.to_dataframe(index="a") + assert df.index.name == "a" + + +def test_to_polars_uses_pandas_then_converts(): + """Force the polars import path via require_optional.""" + pytest.importorskip("polars") + obb = OBBject(results=[{"a": 1, "b": 2}, {"a": 3, "b": 4}]) + out = obb.to_polars() + assert hasattr(out, "to_pandas") + + +def test_to_llm_returns_json_string(): + obb = OBBject(results=[{"a": 1, "b": 2}, {"a": 3, "b": 4}]) + out = obb.to_llm() + assert isinstance(out, str) + assert '"a"' in out + + +def test_show_without_chart_raises(): + obb = OBBject(results=[{"a": 1}]) + with pytest.raises(OpenBBError, match="Chart not found"): + obb.show() + + +def test_show_with_chart_calls_show(): + from unittest.mock import MagicMock + + fake_fig = MagicMock() + obb = OBBject(results=[{"a": 1}]) + obb.chart = MagicMock() + obb.chart.fig = fake_fig + obb.show() + fake_fig.show.assert_called_once() + + +def test_from_query_returns_obbject_with_results(): + import asyncio + from unittest.mock import AsyncMock, MagicMock + + fake_query = MagicMock() + fake_query.execute = AsyncMock(return_value=[{"a": 1}]) + out = asyncio.run(OBBject.from_query(fake_query)) + assert isinstance(out, OBBject) + assert out.results == [{"a": 1}] + + +def test_from_query_with_annotated_result(): + import asyncio + from unittest.mock import AsyncMock, MagicMock + + from openbb_core.provider.abstract.annotated_result import AnnotatedResult + + fake_query = MagicMock() + fake_query.execute = AsyncMock( + return_value=AnnotatedResult(result=[{"a": 1}], metadata={"src": "test"}) + ) + out = asyncio.run(OBBject.from_query(fake_query)) + assert out.results == [{"a": 1}] + assert out.extra["results_metadata"] == {"src": "test"} + + +def test_to_polars_raises_when_polars_missing(monkeypatch): + """When polars is not installed, ``require_optional`` raises ImportError/ModuleNotFoundError.""" + + from openbb_core.app import utils_optional + + real_require = utils_optional.require_optional + + def fake_require(pkg): + if pkg == "polars": + raise ModuleNotFoundError("polars not installed") + return real_require(pkg) + + monkeypatch.setattr(utils_optional, "require_optional", fake_require) + obb = OBBject(results=[{"a": 1, "b": 2}, {"a": 3, "b": 4}]) + with pytest.raises(ModuleNotFoundError): + obb.to_polars() diff --git a/openbb_platform/core/tests/app/model/test_preferences.py b/openbb_platform/core/tests/app/model/test_preferences.py index dacfc708ed9..3d64e712380 100644 --- a/openbb_platform/core/tests/app/model/test_preferences.py +++ b/openbb_platform/core/tests/app/model/test_preferences.py @@ -7,3 +7,11 @@ def test_preferences(): """Test the preferences class.""" preferences = Preferences() assert isinstance(preferences, Preferences) + + +def test_preferences_repr(): + """Test __repr__ returns a formatted string.""" + preferences = Preferences() + result = repr(preferences) + assert "Preferences" in result + assert "chart_style" in result diff --git a/openbb_platform/core/tests/app/model/test_python_settings.py b/openbb_platform/core/tests/app/model/test_python_settings.py new file mode 100644 index 00000000000..44029af1d4a --- /dev/null +++ b/openbb_platform/core/tests/app/model/test_python_settings.py @@ -0,0 +1,18 @@ +"""Test python settings model.""" + +from openbb_core.app.model.python_settings import PythonSettings + + +def test_python_settings_repr_contains_fields(): + settings = PythonSettings(docstring_max_length=120, http={"timeout": 5}) + + rep = repr(settings) + assert rep.startswith("PythonSettings") + assert "docstring_max_length: 120" in rep + assert "http: {'timeout': 5}" in rep + + +def test_python_settings_allows_extra_fields(): + settings = PythonSettings(custom_option=True) + + assert settings.custom_option is True diff --git a/openbb_platform/core/tests/app/model/test_system_settings.py b/openbb_platform/core/tests/app/model/test_system_settings.py index 4366d92d2be..79a2a292220 100644 --- a/openbb_platform/core/tests/app/model/test_system_settings.py +++ b/openbb_platform/core/tests/app/model/test_system_settings.py @@ -21,6 +21,14 @@ def test_system_settings(): assert isinstance(sys, SystemSettings) +def test_system_settings_repr(): + """Test __repr__ returns formatted string with class name and fields.""" + sys = SystemSettings() + result = repr(sys) + assert "SystemSettings" in result + assert "openbb_directory" in result + + def test_create_openbb_directory_directory_and_files_not_exist(tmpdir): """Test the create_openbb_directory method.""" # Arrange diff --git a/openbb_platform/core/tests/app/model/test_user_settings.py b/openbb_platform/core/tests/app/model/test_user_settings.py index 1277669fea5..efbf1a2b62d 100644 --- a/openbb_platform/core/tests/app/model/test_user_settings.py +++ b/openbb_platform/core/tests/app/model/test_user_settings.py @@ -1,5 +1,11 @@ """Test the UserSettings model.""" +import builtins +import json + +import pytest + +from openbb_core.app.model import user_settings as user_settings_module from openbb_core.app.model.credentials import Credentials from openbb_core.app.model.defaults import Defaults from openbb_core.app.model.preferences import Preferences @@ -14,3 +20,65 @@ def test_user_settings(): defaults=Defaults(), ) assert isinstance(settings, UserSettings) + + +def test_user_settings_loads_from_existing_file(tmp_path, monkeypatch): + p = tmp_path / "user_settings.json" + payload = { + "credentials": {"fmp_api_key": "abc"}, + "preferences": {"output_type": "dataframe"}, + "defaults": {"commands": {}}, + } + p.write_text(json.dumps(payload)) + + monkeypatch.setattr(user_settings_module, "USER_SETTINGS_PATH", str(p)) + + settings = UserSettings() + value = settings.credentials.fmp_api_key + secret = value.get_secret_value() if hasattr(value, "get_secret_value") else value + assert secret == payload["credentials"]["fmp_api_key"] + + +def test_user_settings_invalid_json_warns_and_uses_kwargs(tmp_path, monkeypatch): + p = tmp_path / "user_settings.json" + p.write_text("{not-json") + monkeypatch.setattr(user_settings_module, "USER_SETTINGS_PATH", str(p)) + + with pytest.warns(UserWarning, match="Error loading user settings"): + settings = UserSettings(preferences=Preferences(output_type="chart")) + + assert settings.preferences.output_type == "chart" + + +def test_user_settings_oserror_warns_and_uses_kwargs(monkeypatch): + monkeypatch.setattr(user_settings_module.os.path, "exists", lambda _p: True) + + def _raise_oserror(*_a, **_k): + raise OSError("no access") + + monkeypatch.setattr(builtins, "open", _raise_oserror) + + with pytest.warns(UserWarning, match="Error loading user settings"): + settings = UserSettings(defaults=Defaults(commands={"x": {}})) + + assert "x" in settings.defaults.commands + + +def test_user_settings_repr_contains_sections(): + settings = UserSettings( + credentials=Credentials(), + preferences=Preferences(), + defaults=Defaults(), + ) + out = repr(settings) + assert "UserSettings" in out + assert "credentials" in out + assert "preferences" in out + assert "defaults" in out + + +def test_user_settings_no_file_uses_kwargs(monkeypatch): + monkeypatch.setattr(user_settings_module.os.path, "exists", lambda _p: False) + + settings = UserSettings(preferences=Preferences(output_type="chart")) + assert settings.preferences.output_type == "chart" diff --git a/openbb_platform/core/tests/app/service/test_auth_service.py b/openbb_platform/core/tests/app/service/test_auth_service.py index 164f8262647..f2bdb8c831e 100644 --- a/openbb_platform/core/tests/app/service/test_auth_service.py +++ b/openbb_platform/core/tests/app/service/test_auth_service.py @@ -44,9 +44,6 @@ def _isolate_singletons(): _reset_singletons() -# --------------------------- Default fallback path -------------------------- - - def test_auth_service_uses_default_when_no_extension_configured(): """No ``ext_name`` → default core router and default hooks.""" from openbb_core.api.router.user import ( @@ -75,9 +72,6 @@ def test_auth_service_falls_back_when_extension_not_installed(): assert service.auth_hook is default_auth_hook -# --------------------------- Real entry-point path -------------------------- - - def _install_fake_auth_extension(monkeypatch, ext_name: str = "fake_auth"): """Register a real entry point pointing at an in-memory module. diff --git a/openbb_platform/core/tests/app/service/test_system_service.py b/openbb_platform/core/tests/app/service/test_system_service.py index b9feb152a4d..a4018ef90ae 100644 --- a/openbb_platform/core/tests/app/service/test_system_service.py +++ b/openbb_platform/core/tests/app/service/test_system_service.py @@ -1,5 +1,7 @@ """Test the system_service.py module.""" +import json + import pytest from openbb_core.app.service.system_service import SystemService @@ -54,3 +56,55 @@ def test_refresh_system_settings(system_service): system_settings = system_service.refresh_system_settings() assert system_settings + + +def test_compare_hash_with_explicit_hash(): + import hashlib + + h = hashlib.sha256(b"hello").hexdigest() + assert SystemService._compare_hash("hello", existing_hash=h) is True + assert SystemService._compare_hash("hello", existing_hash="0" * 64) is False + + +def test_compare_hash_default_does_not_match_arbitrary_string(): + assert SystemService._compare_hash("definitely-not-the-pro-secret") is False + + +def test_read_from_file_filters_disallowed_fields(tmp_path): + path = tmp_path / "system_settings.json" + path.write_text( + json.dumps( + { + "test_mode": True, + "headless": True, + "this_is_not_allowed": "drop me", + } + ) + ) + settings = SystemService._read_from_file(path=path) + assert settings.test_mode is True + assert settings.headless is True + assert not hasattr(settings, "this_is_not_allowed") + + +def test_read_from_file_logging_sub_app_pro_promotion(tmp_path, monkeypatch): + path = tmp_path / "system_settings.json" + path.write_text(json.dumps({"logging_sub_app": "some-secret"})) + monkeypatch.setattr( + SystemService, "_compare_hash", classmethod(lambda cls, val: True) + ) + settings = SystemService._read_from_file(path=path) + assert settings.logging_sub_app == "pro" + + +def test_read_from_file_logging_sub_app_invalid_dropped(tmp_path): + path = tmp_path / "system_settings.json" + path.write_text(json.dumps({"logging_sub_app": "definitely-not-pro"})) + settings = SystemService._read_from_file(path=path) + assert settings.logging_sub_app != "definitely-not-pro" + + +def test_read_from_file_no_file_uses_kwargs(tmp_path): + path = tmp_path / "missing.json" + settings = SystemService._read_from_file(path=path, headless=True) + assert settings.headless is True diff --git a/openbb_platform/core/tests/app/service/test_user_service.py b/openbb_platform/core/tests/app/service/test_user_service.py index 71acceb774d..c20f9e597f3 100644 --- a/openbb_platform/core/tests/app/service/test_user_service.py +++ b/openbb_platform/core/tests/app/service/test_user_service.py @@ -3,7 +3,9 @@ import json import tempfile from pathlib import Path +from unittest.mock import patch +from openbb_core.app.model.abstract.singleton import SingletonMeta from openbb_core.app.service.user_service import ( UserService, UserSettings, @@ -59,3 +61,43 @@ def test_merge_dicts(): assert isinstance(result, dict) assert result["a"] == 3 assert result["b"] == 4 + + +def test_merge_dicts_recursive_nested(): + result = UserService._merge_dicts( + list_of_dicts=[ + {"a": {"x": 1, "y": 2}}, + {"a": {"y": 3, "z": 4}}, + ] + ) + + assert result == {"a": {"x": 1, "y": 3, "z": 4}} + + +def test_default_user_settings_property_and_setter(): + SingletonMeta._instances.pop(UserService, None) + + initial = UserSettings() + service = UserService(default_user_settings=initial) + + assert service.default_user_settings is initial + + updated = UserSettings(preferences={"theme": "light"}) + service.default_user_settings = updated + + assert service.default_user_settings is updated + + SingletonMeta._instances.pop(UserService, None) + + +def test_init_uses_read_from_file_when_default_not_passed(): + SingletonMeta._instances.pop(UserService, None) + loaded = UserSettings(preferences={"theme": "dark"}) + + with patch.object(UserService, "read_from_file", return_value=loaded) as mock_read: + service = UserService() + + assert service.default_user_settings is loaded + mock_read.assert_called_once() + + SingletonMeta._instances.pop(UserService, None) diff --git a/openbb_platform/core/tests/app/static/package_builder/__init__.py b/openbb_platform/core/tests/app/static/package_builder/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openbb_platform/core/tests/app/static/package_builder/test_docstring_generator_helpers.py b/openbb_platform/core/tests/app/static/package_builder/test_docstring_generator_helpers.py new file mode 100644 index 00000000000..63716399152 --- /dev/null +++ b/openbb_platform/core/tests/app/static/package_builder/test_docstring_generator_helpers.py @@ -0,0 +1,1544 @@ +"""Direct unit tests for static helpers in ``DocstringGenerator``.""" + +from collections import OrderedDict +from datetime import date, datetime +from inspect import Parameter +from typing import Annotated + +import pytest +from pydantic import BaseModel, Field + +pandas = pytest.importorskip("pandas") + +pytestmark = pytest.mark.requires_pandas + +from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, +) + + +def test_get_field_type_simple_required(): + out = DocstringGenerator.get_field_type(int, is_required=True) + assert out == "int" + + +def test_get_field_type_simple_optional_wraps_in_optional(): + out = DocstringGenerator.get_field_type(int, is_required=False) + assert out == "Optional[int]" + + +def test_get_field_type_optional_target_website_strips_optional_wrapper(): + out = DocstringGenerator.get_field_type(int, is_required=False, target="website") + assert out == "int" + + +def test_get_field_type_union_sorts_and_joins(): + out = DocstringGenerator.get_field_type(int | str, is_required=True) + assert out == "int | str" + + +def test_get_field_type_optional_union_appends_none(): + out = DocstringGenerator.get_field_type(int | str | None, is_required=False) + # Sorted alphabetically + None appended at the end + assert out.endswith("| None") + assert "int" in out and "str" in out + + +def test_get_field_type_datetime_aliases_normalized(): + out = DocstringGenerator.get_field_type(datetime, is_required=True) + assert out == "datetime" + + +def test_get_field_type_date_alias_normalized(): + out = DocstringGenerator.get_field_type(date, is_required=True) + assert out == "date" + + +def test_get_field_type_handles_forward_ref(): + from typing import ForwardRef + + fwd = ForwardRef("MyType") + out = DocstringGenerator.get_field_type(fwd, is_required=True) + assert "MyType" in out + + +def test_get_field_type_strips_nonetype_text(): + out = DocstringGenerator.get_field_type(str | None, is_required=False) + assert "NoneType" not in out + + +def test_get_obbject_description_default_provider_placeholder(): + out = DocstringGenerator.get_OBBject_description("MyResults", None) + assert "OBBject" in out + assert "results : MyResults" in out + assert "provider : Optional[str]" in out + assert "warnings" in out + assert "chart" in out + assert "extra" in out + + +def test_get_obbject_description_with_concrete_providers(): + out = DocstringGenerator.get_OBBject_description("MyResults", "fmp, polygon") + assert "provider : fmp, polygon" in out + + +def test_get_obbject_description_replaces_nonetype(): + out = DocstringGenerator.get_OBBject_description("MyResults", None) + assert "NoneType" not in out + + +def test_build_examples_no_examples_returns_empty_string(): + assert DocstringGenerator.build_examples("obb.foo", {}, None) == "" + assert DocstringGenerator.build_examples("obb.foo", {}, []) == "" + + +def test_build_examples_docstring_format(): + from openbb_core.app.model.example import APIEx + + examples = [APIEx(parameters={"symbol": "AAPL"})] + out = DocstringGenerator.build_examples( + "obb.equity.profile", {"symbol": str}, examples + ) + assert "Examples" in out + assert ">>> " in out + assert "from openbb import obb" in out + + +def test_build_examples_website_format_wraps_in_code_fence(): + from openbb_core.app.model.example import APIEx + + examples = [APIEx(parameters={"symbol": "AAPL"})] + out = DocstringGenerator.build_examples( + "obb.equity.profile", {"symbol": str}, examples, target="website" + ) + assert "```python" in out + assert out.endswith("```\n\n") + + +def test_get_generic_types_union_unpacks_to_inner_names(): + out = DocstringGenerator._get_generic_types(list[str] | dict[str, str], []) + assert "list" in out + assert "dict" in out + + +def test_get_generic_types_simple_list_returns_list_name(): + out = DocstringGenerator._get_generic_types(list[int], []) + assert "list" in out + + +def test_get_generic_types_non_generic_returns_empty(): + out = DocstringGenerator._get_generic_types(int, []) + assert out == [] + + +def test_get_repr_single_item_returns_bracketed_string(): + out = DocstringGenerator._get_repr(["list"], "MyModel") + assert out == "list[MyModel]" + + +def test_get_repr_dict_uses_str_key(): + out = DocstringGenerator._get_repr(["dict"], "MyModel") + assert out == "dict[str, MyModel]" + + +def test_get_repr_multiple_items_joined_with_pipe(): + out = DocstringGenerator._get_repr(["list", "dict"], "MyModel") + assert "list[MyModel]" in out + assert "dict[str, MyModel]" in out + assert " | " in out + + +def test_get_repr_empty_items_returns_model_only(): + out = DocstringGenerator._get_repr([], "MyModel") + assert out == "MyModel" + + +from openbb_core.app.model.example import APIEx # noqa: E402 +from openbb_core.app.model.field import OpenBBField # noqa: E402 +from openbb_core.app.model.obbject import OBBject # noqa: E402 + + +def _gen(): + return DocstringGenerator() + + +def test_get_OBBject_description_with_providers(): + out = DocstringGenerator.get_OBBject_description("list[X]", "Literal['a','b']") + assert "list[X]" in out + assert "Literal['a','b']" in out + + +def test_get_OBBject_description_default_providers(): + out = DocstringGenerator.get_OBBject_description("X", None) + assert "Optional[str]" in out + + +def test_get_field_type_handles_forward_ref_extended(): + from typing import ForwardRef + + out = DocstringGenerator.get_field_type(ForwardRef("int"), is_required=True) + assert "ForwardRef" not in out + assert "int" in out + + +def test_get_field_type_optional(): + out = DocstringGenerator.get_field_type(int | None, is_required=False) + assert "int" in out + + +def test_build_examples_docstring_target(): + out = DocstringGenerator.build_examples( + func_path="openbb.test.command", + param_types={"symbol": str}, + examples=[APIEx(parameters={"symbol": "AAPL"})], + target="docstring", + ) + assert "Examples" in out + assert ">>> from openbb import obb" in out + + +def test_build_examples_website_target(): + out = DocstringGenerator.build_examples( + func_path="openbb.test.command", + param_types={"symbol": str}, + examples=[APIEx(parameters={"symbol": "AAPL"})], + target="website", + ) + assert "```python" in out + assert out.strip().endswith("```") + + +def test_build_examples_empty_returns_empty(): + out = DocstringGenerator.build_examples("p", {}, None) + assert out == "" + out = DocstringGenerator.build_examples("p", {}, []) + assert out == "" + + +def _func_with_no_doc(symbol: Annotated[str, OpenBBField(description="The sym.")]): + pass + + +def _func_with_doc(symbol: Annotated[str, OpenBBField(description="")]) -> str: + """Top-level summary.""" + return symbol + + +async def _func_obbject( + symbol: Annotated[str, OpenBBField(description="X")], +) -> OBBject: + """An OBBject command.""" + + +def _func_no_params() -> int: + """Plain int return.""" + return 1 + + +def test_generate_no_model_basic_no_params(): + g = _gen() + out = g.generate( + path="/test/cmd", + func=_func_no_params, + formatted_params=OrderedDict(), + model_name=None, + ) + assert out + assert "Returns" in out + + +def test_generate_no_model_with_params(): + g = _gen() + fp = OrderedDict( + { + "symbol": Parameter( + name="symbol", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="The symbol.")], + ) + } + ) + # Patch _annotation since generate inspects param._annotation + fp["symbol"]._annotation = Annotated[str, OpenBBField(description="The symbol.")] + out = g.generate( + path="/test/cmd", + func=_func_with_doc, + formatted_params=fp, + model_name=None, + ) + assert out + assert "Parameters" in out + assert "symbol" in out + + +def test_generate_no_model_obbject_return(): + g = _gen() + fp = OrderedDict( + { + "symbol": Parameter( + name="symbol", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="X")], + ) + } + ) + fp["symbol"]._annotation = Annotated[str, OpenBBField(description="X")] + out = g.generate( + path="/test/cmd", + func=_func_obbject, + formatted_params=fp, + model_name=None, + ) + assert out + + +def test_generate_no_model_with_examples(): + g = _gen() + out = g.generate( + path="/test/cmd", + func=_func_no_params, + formatted_params=OrderedDict(), + model_name=None, + examples=[APIEx(parameters={})], + ) + assert out + + +class _RetModel(BaseModel): + """A return model.""" + + foo: str = Field(default="x", description="The foo field.") + bar: int | None = Field(default=None, description="A bar number.") + + +def _func_returns_model() -> _RetModel: + """Plain model return.""" + + +def test_generate_returns_section_non_obbject_model_fields(): + g = _gen() + out = g.generate( + path="/test/cmd2", + func=_func_returns_model, + formatted_params=OrderedDict(), + model_name=None, + ) + assert "Returns" in out + assert "_RetModel" in out or "RetModel" in out + + +def test_generate_obbject_inner_type_extraction(monkeypatch): + """Drive lines ~971-996 by ensuring ReferenceGenerator.get_paths returns data + for the function's path, so the schema fields are appended to the docstring.""" + + from openbb_core.app.static.package_builder import ( + path_handler as ph_mod, + reference_generator as rg_mod, + ) + + fake_field = { + "name": "symbol", + "type": "str", + "description": "Sym field.", + } + + def fake_paths(_route_map): + return {"/test/inner": {"data": {"standard": [fake_field]}}} + + monkeypatch.setattr(ph_mod.PathHandler, "build_route_map", lambda: {}) + monkeypatch.setattr( + rg_mod.ReferenceGenerator, + "get_paths", + classmethod(lambda cls, _rm: fake_paths(_rm)), + ) + + async def _func_obbject_typed() -> OBBject[list[_RetModel]]: + """Typed OBBject.""" + + g = _gen() + out = g.generate( + path="/test/inner", + func=_func_obbject_typed, + formatted_params=OrderedDict(), + model_name=None, + ) + assert "symbol" in out + assert "Sym field." in out + + +def test_generate_model_docstring_format_type_union_literal(): + """Lines 252-281: format_type Union/Literal/dedup branches via real model docstring.""" + from inspect import Parameter + from typing import Annotated, Literal + + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + p1 = Parameter( + name="kind", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[Literal["a", "b"] | int | None, "tag"], + default=None, + ) + object.__setattr__( + p1, "_annotation", Annotated[Literal["a", "b"] | int | None, "tag"] + ) + p2 = Parameter( + name="vals", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=str | int | None, + default=None, + ) + object.__setattr__(p2, "_annotation", str | int | None) + + explicit_params = {"kind": p1, "vals": p2} + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params=explicit_params, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "kind" in out + assert "vals" in out + assert "str" in out and "int" in out + + +def test_get_field_type_beforevalidator(): + """Test BeforeValidator path -> int / Optional[int].""" + from typing import Annotated + + from pydantic import BeforeValidator + + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + t = Annotated[int, BeforeValidator(lambda v: v)] + assert "int" in DocstringGenerator.get_field_type(t, False, "docstring") + assert ( + DocstringGenerator.get_field_type(t, False, "docstring") == "Optional[int]" + or DocstringGenerator.get_field_type(t, False, "docstring") == "int" + ) + + +def test_get_field_type_union_with_literal_skip(): + """Lines ~108: union member with Literal origin is skipped.""" + from typing import Literal + + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + out = DocstringGenerator.get_field_type( + Literal["a", "b"] | int | None, True, "docstring" + ) + assert "int" in out + + +def test_get_field_type_typeerror_fallback(): + """Lines 159-160: TypeError -> str(field_type) fallback.""" + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + class _Bad: + def __str__(self): + return "BadThing" + + out = DocstringGenerator.get_field_type(_Bad(), False, "docstring") + assert isinstance(out, str) + + +def test_generate_model_docstring_kwarg_query_json_extra_choices(): + from types import SimpleNamespace + from typing import Annotated, Literal + + from fastapi import Query + + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + kwarg_param = SimpleNamespace( + _annotation=Annotated[ + Literal["one", "two"], + OpenBBField(description="Choice value."), + ], + type=Literal["one", "two"], + default=Query( + default=None, + description="", + json_schema_extra={ + "tmx": { + "choices": ["one", "two"], + "multiple_items_allowed": True, + } + }, + ), + annotation=Annotated[ + Literal["one", "two"], + OpenBBField(description="Choice value."), + ], + ) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={}, + kwarg_params={"choice": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + + assert "choice" in out + assert "Choices for tmx" in out + + +def test_get_field_type_openbb_path_is_shortened(): + out = DocstringGenerator.get_field_type( + "openbb_core.provider.abstract.data.Data", is_required=True + ) + assert out == "Data" + + +def test_get_field_type_union_openbb_name_branch(monkeypatch): + from types import UnionType + + from openbb_core.app.static.package_builder import docstring_generator as dg + + class openbb_model_type: + pass + + monkeypatch.setattr(dg, "Union", UnionType) + out = DocstringGenerator.get_field_type(openbb_model_type | str, is_required=True) + assert "openbb_model_type" in out + + +def test_generate_model_docstring_provider_extraction_handles_missing_model_providers(): + from inspect import Parameter + from types import SimpleNamespace, UnionType + + from openbb_core.app.static.package_builder import docstring_generator as dg + + original_provider_interface = DocstringGenerator.provider_interface + try: + original_union = dg.Union + dg.Union = UnionType + DocstringGenerator.provider_interface = SimpleNamespace( + model_providers={}, + map={}, + ) + p = Parameter( + name="provider", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=str | int, + default=None, + ) + object.__setattr__(p, "_annotation", str | int) + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"provider": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "provider" in out + finally: + DocstringGenerator.provider_interface = original_provider_interface + dg.Union = original_union + + +def test_generate_model_docstring_provider_choices_handles_attribute_error_in_map(): + from dataclasses import make_dataclass + from inspect import Parameter + from types import SimpleNamespace, UnionType + from typing import Literal + + from openbb_core.app.static.package_builder import docstring_generator as dg + + class _BadMap: + def get(self, *_args, **_kwargs): + raise AttributeError("boom") + + original_provider_interface = DocstringGenerator.provider_interface + try: + original_union = dg.Union + dg.Union = UnionType + ModelProviders = make_dataclass("ModelProviders", [("provider", object)]) + ProviderField = type("F", (), {"type": Literal["alpha"]}) + ModelProviders.__dataclass_fields__["provider"] = ProviderField + DocstringGenerator.provider_interface = SimpleNamespace( + model_providers={"NoSuchModelXYZ": ModelProviders("alpha")}, + map=_BadMap(), + ) + + p = Parameter( + name="provider", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=str | int, + default=None, + ) + object.__setattr__(p, "_annotation", str | int) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"provider": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "provider" in out + finally: + DocstringGenerator.provider_interface = original_provider_interface + dg.Union = original_union + + +def test_generate_model_docstring_provider_choices_handles_missing_model_provider_entry( + monkeypatch, +): + from inspect import Parameter + from types import SimpleNamespace, UnionType + + from openbb_core.app.static.package_builder import docstring_generator as dg + + monkeypatch.setattr(dg, "Union", UnionType) + monkeypatch.setattr( + DocstringGenerator, + "provider_interface", + SimpleNamespace(model_providers={"M": None}, map={}), + ) + + p = Parameter( + name="provider", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=str | int, + default=None, + ) + object.__setattr__(p, "_annotation", str | int) + + out = DocstringGenerator.generate_model_docstring( + model_name="M", + summary="S", + explicit_params={"provider": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "provider" in out + + +def test_generate_model_docstring_forced_union_with_missing_model_providers( + monkeypatch, +): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import docstring_generator as dg + + union_sentinel = object() + + class _FakeUnionType: + __origin__ = union_sentinel + + kwarg_param = SimpleNamespace( + _annotation=_FakeUnionType, + type=_FakeUnionType, + default=SimpleNamespace(description="d", json_schema_extra=None), + annotation=_FakeUnionType, + ) + + monkeypatch.setattr(dg, "Union", union_sentinel) + monkeypatch.setattr( + DocstringGenerator, + "provider_interface", + SimpleNamespace(model_providers={}, map={}), + ) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={}, + kwarg_params={"x": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "x" in out + + +def test_generate_model_docstring_forced_union_provider_map_attribute_error( + monkeypatch, +): + from dataclasses import make_dataclass + from types import SimpleNamespace + from typing import Literal + + from openbb_core.app.static.package_builder import docstring_generator as dg + + union_sentinel = object() + + class _FakeUnionType: + __origin__ = union_sentinel + + class _BadMap: + def get(self, *_args, **_kwargs): + raise AttributeError("boom") + + ModelProviders = make_dataclass("ModelProvidersX", [("provider", object)]) + ProviderField = type("F", (), {"type": Literal["alpha"]}) + ModelProviders.__dataclass_fields__["provider"] = ProviderField + + kwarg_param = SimpleNamespace( + _annotation=_FakeUnionType, + type=_FakeUnionType, + default=SimpleNamespace(description="d", json_schema_extra=None), + annotation=_FakeUnionType, + ) + + monkeypatch.setattr(dg, "Union", union_sentinel) + monkeypatch.setattr( + DocstringGenerator, + "provider_interface", + SimpleNamespace( + model_providers={"NoSuchModelXYZ": ModelProviders("alpha")}, + map=_BadMap(), + ), + ) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={}, + kwarg_params={"x": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "x" in out + + +def test_generate_model_docstring_forced_union_model_providers_attribute_error( + monkeypatch, +): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import docstring_generator as dg + + union_sentinel = object() + + class _FakeUnionType: + __origin__ = union_sentinel + + class _BadModelProviders: + def get(self, *_args, **_kwargs): + raise AttributeError("boom") + + kwarg_param = SimpleNamespace( + _annotation=_FakeUnionType, + type=_FakeUnionType, + default=SimpleNamespace(description="d", json_schema_extra=None), + annotation=_FakeUnionType, + ) + + monkeypatch.setattr(dg, "Union", union_sentinel) + monkeypatch.setattr( + DocstringGenerator, + "provider_interface", + SimpleNamespace(model_providers=_BadModelProviders(), map={}), + ) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={}, + kwarg_params={"x": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "x" in out + + +def test_generate_model_docstring_kwarg_provider_map_attribute_error(monkeypatch): + from dataclasses import dataclass + from types import SimpleNamespace + from typing import Annotated, Literal + + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + @dataclass + class _MP: + provider: Literal["prov1"] + + kwarg_param = SimpleNamespace( + _annotation=Annotated[str | int, OpenBBField(description="d")], + type=str | int, + default=SimpleNamespace(description="d", json_schema_extra=None), + annotation=Annotated[str | int, OpenBBField(description="d")], + ) + + fake_pi = SimpleNamespace(model_providers={"M": _MP}, map=object()) + monkeypatch.setattr(DocstringGenerator, "provider_interface", fake_pi) + + out = DocstringGenerator.generate_model_docstring( + model_name="M", + summary="S", + explicit_params={}, + kwarg_params={"x": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "x" in out + + +def test_generate_model_docstring_kwarg_model_providers_attribute_error(monkeypatch): + from types import SimpleNamespace + from typing import Annotated + + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + kwarg_param = SimpleNamespace( + _annotation=Annotated[str | int, OpenBBField(description="d")], + type=str | int, + default=SimpleNamespace(description="d", json_schema_extra=None), + annotation=Annotated[str | int, OpenBBField(description="d")], + ) + + fake_pi = SimpleNamespace(model_providers={"M": object()}, map={}) + monkeypatch.setattr(DocstringGenerator, "provider_interface", fake_pi) + + out = DocstringGenerator.generate_model_docstring( + model_name="M", + summary="S", + explicit_params={}, + kwarg_params={"x": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "x" in out + + +def test_generate_non_model_return_generic_alias_type_name_path(monkeypatch): + from openbb_core.app.static.package_builder import docstring_generator as dg + + class _SS: + class _PS: + docstring_sections = ["returns"] + docstring_max_length = None + + python_settings = _PS() + + class _Svc: + system_settings = _SS() + + monkeypatch.setattr(dg, "SystemService", _Svc) + + def _func() -> list[int]: + return [1] + + out = DocstringGenerator.generate( + path="/x/y", + func=_func, + formatted_params=OrderedDict(), + model_name=None, + ) + assert "Returns" in out + assert "list" in out + + +def test_generate_non_model_return_model_fields_exception_path(monkeypatch): + from openbb_core.app.static.package_builder import docstring_generator as dg + + class _SS: + class _PS: + docstring_sections = ["returns"] + docstring_max_length = None + + python_settings = _PS() + + class _Svc: + system_settings = _SS() + + class _BadFields: + def items(self): + raise TypeError("boom") + + class _BadRet: + model_fields = _BadFields() + + monkeypatch.setattr(dg, "SystemService", _Svc) + + def _func() -> _BadRet: + return _BadRet() + + out = DocstringGenerator.generate( + path="/x/y", + func=_func, + formatted_params=OrderedDict(), + model_name=None, + ) + assert "Returns" in out + + +def test_generate_non_model_doc_truncation_max_length(monkeypatch): + from openbb_core.app.static.package_builder import docstring_generator as dg + + class _SS: + class _PS: + docstring_sections = ["description"] + docstring_max_length = 20 + + python_settings = _PS() + + class _Svc: + system_settings = _SS() + + monkeypatch.setattr(dg, "SystemService", _Svc) + + def _func(): + """A very long summary line for truncation testing.""" + + out = DocstringGenerator.generate( + path="/x/y", + func=_func, + formatted_params=OrderedDict(), + model_name=None, + ) + assert out.endswith("...") + + +def test_generate_model_docstring_semicolon_provider_sections_with_choices(): + from inspect import Parameter + from typing import Annotated + + desc = ( + "Base desc. Alpha detail (provider: a); " + "Base desc. Beta detail (provider: b)\n" + "Choices for a: 'x', 'y'\n" + "Choices for b: 'z'\n" + "Multiple comma separated items allowed for provider(s): a." + ) + + p = Parameter( + name="kind", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description=desc)], + default=None, + ) + object.__setattr__(p, "_annotation", Annotated[str, OpenBBField(description=desc)]) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"kind": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + + assert "(provider: a)" in out + assert "(provider: b)" in out + assert "Choices:" in out + assert "Multiple comma separated items allowed" in out + + +def test_generate_model_docstring_choices_without_provider_sections(): + from inspect import Parameter + from typing import Annotated + + desc = "Simple description\nChoices for a: 'x', 'y'" + p = Parameter( + name="kind", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description=desc)], + default=None, + ) + object.__setattr__(p, "_annotation", Annotated[str, OpenBBField(description=desc)]) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"kind": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + + assert "Choices for a" in out + + +def test_generate_model_docstring_chart_param_branch(): + from inspect import Parameter + from typing import Annotated + + provider = Parameter( + name="provider", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="provider desc")], + default=None, + ) + object.__setattr__( + provider, + "_annotation", + Annotated[str, OpenBBField(description="provider desc")], + ) + + chart = Parameter( + name="chart", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[bool, OpenBBField(description="chart desc")], + default=False, + ) + object.__setattr__( + chart, "_annotation", Annotated[bool, OpenBBField(description="chart desc")] + ) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"provider": provider, "chart": chart}, + kwarg_params={}, + returns={}, + results_type="X", + sections=["parameters"], + ) + assert "chart : bool" in out + + +def test_generate_model_docstring_kwarg_choices_wrapping_and_multi_items(): + from types import SimpleNamespace + from typing import Annotated, Literal + + choices = [f"item_{i:02d}" for i in range(20)] + kwarg_param = SimpleNamespace( + _annotation=Annotated[ + Literal["seed"], OpenBBField(description="(provider: wrap)") + ], + type=Literal["seed"], + default=SimpleNamespace( + description="(provider: wrap)", + json_schema_extra={ + "wrap": { + "choices": choices, + "multiple_items_allowed": True, + } + }, + ), + annotation=Annotated[ + Literal["seed"], OpenBBField(description="(provider: wrap)") + ], + ) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={}, + kwarg_params={"wrapped": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "Choices for wrap" in out + assert "Multiple comma separated items allowed" in out + + +def test_get_repr_returns_model_when_items_empty(): + out = DocstringGenerator._get_repr([], "MyModel") + assert out == "MyModel" + + +def test_generate_no_model_parameters_section_prefix_newline_paths(monkeypatch): + from inspect import Parameter + from typing import Annotated + + from openbb_core.app.static.package_builder import docstring_generator as dg + + class _SS: + class _PS: + docstring_sections = ["parameters"] + docstring_max_length = None + + python_settings = _PS() + + class _Svc: + system_settings = _SS() + + monkeypatch.setattr(dg, "SystemService", _Svc) + + def _func(): + """desc""" + + p_annot = Parameter( + name="x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="x desc")], + default=Parameter.empty, + ) + object.__setattr__( + p_annot, "_annotation", Annotated[str, OpenBBField(description="x desc")] + ) + p_plain = Parameter( + name="y", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=int, + default=1, + ) + object.__setattr__(p_plain, "_annotation", int) + p_kwargs = Parameter( + name="kwargs", + kind=Parameter.VAR_KEYWORD, + annotation=dict, + default=Parameter.empty, + ) + object.__setattr__(p_kwargs, "_annotation", dict) + + out = DocstringGenerator.generate( + path="/x/y", + func=_func, + formatted_params=OrderedDict({"x": p_annot, "y": p_plain, "kwargs": p_kwargs}), + model_name=None, + ) + assert "Parameters" in out + assert "x :" in out and "y :" in out + assert "kwargs" not in out + + +def test_generate_model_docstring_kwarg_literal_provider_from_description_fallback(): + from types import SimpleNamespace + from typing import Annotated, Literal + + kwarg_param = SimpleNamespace( + _annotation=Annotated[ + Literal["a", "b"], OpenBBField(description="(provider: p1, p2)") + ], + type=Literal["a", "b"], + default=SimpleNamespace( + description="(provider: p1, p2)", json_schema_extra=None + ), + annotation=Annotated[ + Literal["a", "b"], OpenBBField(description="(provider: p1, p2)") + ], + ) + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={}, + kwarg_params={"k": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "Choices for p1" in out and "Choices for p2" in out + + +def test_get_field_type_openbb_name_cleanup_branch(): + class openbb_fake_model: + __module__ = "openbb_fake.mod" + + out = DocstringGenerator.get_field_type(openbb_fake_model, is_required=True) + assert isinstance(out, str) + + +def test_get_field_type_union_openbb_name_cleanup_in_union(): + class openbb_union_model: + pass + + out = DocstringGenerator.get_field_type( + openbb_union_model | None, + is_required=True, + ) + assert "openbb_union_model" in out + + +def test_get_field_type_typeerror_path_with_monkeypatch(monkeypatch): + from openbb_core.app.static.package_builder import docstring_generator as dg + + monkeypatch.setattr( + dg, "get_origin", lambda _t: (_ for _ in ()).throw(TypeError("x")) + ) + out = DocstringGenerator.get_field_type(str, is_required=True) + assert isinstance(out, str) + + +def test_generate_model_docstring_format_type_optional_union_literal_and_charlimit(): + from inspect import Parameter + + p1 = Parameter( + name="opt", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation="Optional[int]", + default=None, + ) + object.__setattr__(p1, "_annotation", "Optional[int]") + + p2 = Parameter( + name="unioned", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation="Union[list[str], dict[str, int], None]", + default=None, + ) + object.__setattr__(p2, "_annotation", "Union[list[str], dict[str, int], None]") + + p3 = Parameter( + name="lit", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation="Literal['a', 'b'] | None", + default=None, + ) + object.__setattr__(p3, "_annotation", "Literal['a', 'b'] | None") + + p4 = Parameter( + name="long", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation="dict[str, list[dict[str, list[dict[str, list[str]]]]]]", + default=None, + ) + object.__setattr__( + p4, + "_annotation", + "dict[str, list[dict[str, list[dict[str, list[str]]]]]]", + ) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"opt": p1, "unioned": p2, "lit": p3, "long": p4}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "opt" in out + assert "unioned" in out + assert "str | None" in out or "str" in out + assert "lit" in out + + +def test_generate_model_docstring_semicolon_base_and_multi_pattern_paths(): + from inspect import Parameter + from typing import Annotated + + desc = ( + "Base text; Alpha content (provider: a); Beta content (provider: b)\n" + "Multiple comma separated items allowed for provider(s): a." + ) + p = Parameter( + name="k", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description=desc)], + default=None, + ) + object.__setattr__(p, "_annotation", Annotated[str, OpenBBField(description=desc)]) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"k": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "(provider: a)" in out + assert "(provider: b)" in out + assert "Multiple comma separated items allowed" in out + + +def test_generate_model_docstring_model_providers_missing_branch(monkeypatch): + from types import SimpleNamespace + from typing import Annotated + + kwarg_param = SimpleNamespace( + _annotation=Annotated[str | int, OpenBBField(description="d")], + type=str | int, + default=SimpleNamespace(description="d", json_schema_extra=None), + annotation=Annotated[str | int, OpenBBField(description="d")], + ) + fake_pi = SimpleNamespace(model_providers={}, map={}) + monkeypatch.setattr(DocstringGenerator, "provider_interface", fake_pi) + + out = DocstringGenerator.generate_model_docstring( + model_name="MissingModel", + summary="S", + explicit_params={}, + kwarg_params={"k": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "k" in out + + +def test_generate_model_docstring_literal_none_append_branch(monkeypatch): + from inspect import Parameter + + from openbb_core.app.static.package_builder import docstring_generator as dg + + original_sub = dg.re.sub + + def fake_sub(pattern, repl, text): + if pattern == r"Literal\[[^\]]+\]": + return "str" + return original_sub(pattern, repl, text) + + monkeypatch.setattr(dg.re, "sub", fake_sub) + + p = Parameter( + name="lit", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation="Literal['a'] | None", + default=None, + ) + object.__setattr__(p, "_annotation", "Literal['a'] | None") + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"lit": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "lit" in out + + +def test_generate_model_docstring_char_limit_truncation_branch(): + from inspect import Parameter + + very_long = "dict[str, list[dict[str, list[dict[str, list[dict[str, list[dict[str, list[str]]]]]]]]]" + p = Parameter( + name="long", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=very_long, + default=None, + ) + object.__setattr__(p, "_annotation", very_long) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"long": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "long" in out + + +def test_generate_model_docstring_provider_content_empty_continue_branch(): + from inspect import Parameter + from typing import Annotated + + desc = "Base (provider: a); Base (provider: b)" + p = Parameter( + name="k", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description=desc)], + default=None, + ) + object.__setattr__(p, "_annotation", Annotated[str, OpenBBField(description=desc)]) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"k": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "k" in out + + +def test_generate_model_docstring_format_type_union_nested_and_none(): + from inspect import Parameter + from typing import Annotated + + p = Parameter( + name="z", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[list[str] | dict[str, int] | None, "tag"], + default=None, + ) + object.__setattr__( + p, "_annotation", Annotated[list[str] | dict[str, int] | None, "tag"] + ) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={"z": p}, + kwarg_params={}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "list[str]" in out + assert "dict[str, int]" in out + assert "None" in out + + +def test_generate_model_docstring_kwarg_annotation_metadata_description_branch(): + from types import SimpleNamespace + + annotation = SimpleNamespace( + __origin__=Annotated, + __metadata__=[SimpleNamespace(), OpenBBField(description="meta-desc")], + ) + kwarg_param = SimpleNamespace( + _annotation=str, + type=str, + default=SimpleNamespace(description="", json_schema_extra=None), + annotation=annotation, + ) + + out = DocstringGenerator.generate_model_docstring( + model_name="NoSuchModelXYZ", + summary="S", + explicit_params={}, + kwarg_params={"k": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "meta-desc" in out + + +def test_generate_model_docstring_provider_literal_choices_from_map(monkeypatch): + from dataclasses import dataclass + from types import SimpleNamespace + from typing import Annotated, Literal + + @dataclass + class _MP: + provider: Literal["openbb", "provx"] + + kwarg_param = SimpleNamespace( + _annotation=Annotated[str | int, OpenBBField(description="d")], + type=str | int, + default=SimpleNamespace(description="d", json_schema_extra=None), + annotation=Annotated[str | int, OpenBBField(description="d")], + ) + + provider_field_info = SimpleNamespace(annotation=Literal["one", "two"]) + fake_pi = SimpleNamespace( + model_providers={"M": _MP}, + map={"M": {"provx": {"QueryParams": {"fields": {"k": provider_field_info}}}}}, + ) + monkeypatch.setattr(DocstringGenerator, "provider_interface", fake_pi) + + out = DocstringGenerator.generate_model_docstring( + model_name="M", + summary="S", + explicit_params={}, + kwarg_params={"k": kwarg_param}, + returns={}, + results_type="", + sections=["parameters"], + ) + assert "k :" in out + assert "Choices for provx" in out or "d" in out + + +def test_generate_no_model_parameters_section_empty_result_doc(monkeypatch): + from inspect import Parameter + from typing import Annotated + + from openbb_core.app.static.package_builder import docstring_generator as dg + + class _SS: + class _PS: + docstring_sections = ["parameters"] + docstring_max_length = None + + python_settings = _PS() + + class _Svc: + system_settings = _SS() + + monkeypatch.setattr(dg, "SystemService", _Svc) + + def _func(): + return None + + p_annot = Parameter( + name="x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="x desc")], + default=Parameter.empty, + ) + object.__setattr__( + p_annot, "_annotation", Annotated[str, OpenBBField(description="x desc")] + ) + + out = DocstringGenerator.generate( + path="/x/empty", + func=_func, + formatted_params=OrderedDict({"x": p_annot}), + model_name=None, + ) + assert "Parameters" in out + + +def test_generate_no_model_returns_str_type_name_and_any_fallback(monkeypatch): + import inspect + + from openbb_core.app.static.package_builder import docstring_generator as dg + + class _SS: + class _PS: + docstring_sections = ["returns"] + docstring_max_length = None + + python_settings = _PS() + + class _Svc: + system_settings = _SS() + + class _Sig: + return_annotation = object() + + monkeypatch.setattr(dg, "SystemService", _Svc) + monkeypatch.setattr(dg.inspect, "signature", lambda _f: _Sig()) + + def _func(): + return None + + out = DocstringGenerator.generate( + path="/x/ret-str", + func=_func, + formatted_params=OrderedDict(), + model_name=None, + ) + assert "object" in out + + class _EmptySig: + return_annotation = inspect._empty + + monkeypatch.setattr(dg.inspect, "signature", lambda _f: _EmptySig()) + out2 = DocstringGenerator.generate( + path="/x/ret-any", + func=_func, + formatted_params=OrderedDict(), + model_name=None, + ) + assert "Any" in out2 diff --git a/openbb_platform/core/tests/app/static/package_builder/test_file_lock.py b/openbb_platform/core/tests/app/static/package_builder/test_file_lock.py new file mode 100644 index 00000000000..9fdd429b9a2 --- /dev/null +++ b/openbb_platform/core/tests/app/static/package_builder/test_file_lock.py @@ -0,0 +1,174 @@ +"""Tests for openbb_core.app.static.package_builder.file_lock.""" + +import importlib +import sys +from types import SimpleNamespace + +import pytest + +from openbb_core.app.static.package_builder import file_lock as file_lock_module +from openbb_core.app.static.package_builder.file_lock import _HAS_FCNTL, FileLock + + +@pytest.mark.skipif(not _HAS_FCNTL, reason="fcntl required") +def test_file_lock_acquire_and_release(tmp_path): + p = tmp_path / "lock.txt" + p.write_text("") + with p.open("w") as f: + lock = FileLock(f) + lock.acquire() + lock.release() + + +@pytest.mark.skipif(not _HAS_FCNTL, reason="fcntl required") +def test_file_lock_non_blocking(tmp_path): + p = tmp_path / "lock.txt" + p.write_text("") + with p.open("w") as f: + lock = FileLock(f) + lock.acquire(blocking=False) + lock.release() + + +def test_file_lock_release_swallows_errors(): + class Bad: + def fileno(self): + raise OSError("nope") + + def seek(self, _): + raise OSError("nope") + + FileLock(Bad()).release() + + +@pytest.mark.skipif(not _HAS_FCNTL, reason="fcntl required") +def test_file_lock_second_acquire_non_blocking_raises(tmp_path): + p = tmp_path / "lock.txt" + p.write_text("") + f1 = p.open("w") + f2 = p.open("w") + try: + FileLock(f1).acquire() + with pytest.raises(BlockingIOError): + FileLock(f2).acquire(blocking=False) + finally: + FileLock(f1).release() + f1.close() + f2.close() + + +def test_file_lock_windows_non_blocking_error_is_normalized(monkeypatch): + class _MSVCRT: + LK_LOCK = 1 + LK_NBLCK = 2 + LK_UNLCK = 3 + + @staticmethod + def locking(_fd, _mode, _size): + raise OSError("busy") + + class _File: + def seek(self, _pos): + return None + + def fileno(self): + return 1 + + monkeypatch.setattr(file_lock_module, "_HAS_FCNTL", False) + monkeypatch.setattr(file_lock_module, "msvcrt", _MSVCRT, raising=False) + + with pytest.raises(BlockingIOError): + FileLock(_File()).acquire(blocking=False) + + +def test_file_lock_windows_release_unlock_error_is_ignored(monkeypatch): + class _MSVCRT: + LK_LOCK = 1 + LK_NBLCK = 2 + LK_UNLCK = 3 + + @staticmethod + def locking(_fd, _mode, _size): + raise OSError("unlock failed") + + class _File: + def seek(self, _pos): + return None + + def fileno(self): + return 1 + + monkeypatch.setattr(file_lock_module, "_HAS_FCNTL", False) + monkeypatch.setattr(file_lock_module, "msvcrt", _MSVCRT, raising=False) + + FileLock(_File()).release() + + +def test_file_lock_windows_blocking_mode_branch(monkeypatch): + calls = [] + + class _MSVCRT: + LK_LOCK = 1 + LK_NBLCK = 2 + LK_UNLCK = 3 + + @staticmethod + def locking(_fd, mode, _size): + calls.append(mode) + + class _File: + def seek(self, _pos): + return None + + def fileno(self): + return 1 + + monkeypatch.setattr(file_lock_module, "_HAS_FCNTL", False) + monkeypatch.setattr(file_lock_module, "msvcrt", _MSVCRT, raising=False) + + FileLock(_File()).acquire(blocking=True) + assert calls == [_MSVCRT.LK_LOCK] + + +def test_file_lock_forced_fcntl_branch_via_reload(monkeypatch): + calls = [] + + def _flock(fd, flags): + calls.append((fd, flags)) + + fake_fcntl = SimpleNamespace(LOCK_EX=1, LOCK_NB=2, LOCK_UN=4, flock=_flock) + + monkeypatch.setitem(sys.modules, "fcntl", fake_fcntl) + reloaded = importlib.reload(file_lock_module) + + class _File: + def fileno(self): + return 11 + + lock = reloaded.FileLock(_File()) + lock.acquire(blocking=False) + lock.release() + + assert reloaded._HAS_FCNTL is True + assert calls == [ + (11, fake_fcntl.LOCK_EX | fake_fcntl.LOCK_NB), + (11, fake_fcntl.LOCK_UN), + ] + + monkeypatch.delitem(sys.modules, "fcntl", raising=False) + importlib.reload(file_lock_module) + + +def test_file_lock_forced_fcntl_release_outer_exception(monkeypatch): + def _flock(_fd, _flags): + return None + + fake_fcntl = SimpleNamespace(LOCK_EX=1, LOCK_NB=2, LOCK_UN=4, flock=_flock) + monkeypatch.setattr(file_lock_module, "_HAS_FCNTL", True) + monkeypatch.setattr(file_lock_module, "fcntl", fake_fcntl, raising=False) + + class _Bad: + def fileno(self): + raise RuntimeError("boom") + + FileLock(_Bad()).release() diff --git a/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py b/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py new file mode 100644 index 00000000000..55516fbd11e --- /dev/null +++ b/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py @@ -0,0 +1,1419 @@ +"""Direct unit tests for static helpers in ``MethodDefinition``.""" + +from collections import OrderedDict +from dataclasses import dataclass, field +from inspect import Parameter +from typing import Annotated, Literal, Union +from unittest.mock import patch + +import pytest + +pandas = pytest.importorskip("pandas") + +pytestmark = pytest.mark.requires_pandas + +from fastapi import Request +from pydantic import Field +from pydantic.fields import FieldInfo + +from openbb_core.app.model.field import OpenBBField +from openbb_core.app.provider_interface import ProviderInterface +from openbb_core.app.static.package_builder import method_definition as md_module +from openbb_core.app.static.package_builder.method_definition import MethodDefinition +from openbb_core.provider.abstract.data import Data +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_core.provider.abstract.provider import Provider +from openbb_core.provider.abstract.query_params import QueryParams +from openbb_core.provider.registry import Registry +from openbb_core.provider.registry_map import RegistryMap + + +def test_snake_case_camel_to_snake(): + assert MethodDefinition._snake_case("MyClassName") == "my_class_name" + + +def test_snake_case_dot_to_underscore(): + assert MethodDefinition._snake_case("a.b.c") == "a_b_c" + + +def test_snake_case_already_snake(): + assert MethodDefinition._snake_case("already_snake") == "already_snake" + + +def test_snake_case_empty_string(): + assert MethodDefinition._snake_case("") == "" + + +def test_snake_case_acronym_followed_by_lowercase(): + # "AAPLPriceData" -> handles ABBR + Word + out = MethodDefinition._snake_case("HTTPResponse") + assert "http" in out + assert "response" in out + + +def test_is_none_like_return_none_type(): + assert MethodDefinition._is_none_like_return(None) is True + assert MethodDefinition._is_none_like_return(type(None)) is True + + +def test_is_none_like_return_inspect_empty(): + import inspect + + assert MethodDefinition._is_none_like_return(inspect._empty) is False + + +def test_is_none_like_return_string_none(): + assert MethodDefinition._is_none_like_return("None") is True + assert MethodDefinition._is_none_like_return("nonetype") is True + assert MethodDefinition._is_none_like_return("typing.None") is True + + +def test_is_none_like_return_string_other(): + assert MethodDefinition._is_none_like_return("int") is False + + +def test_is_none_like_return_optional_int_is_false(): + assert MethodDefinition._is_none_like_return(int | None) is False + + +def test_is_none_like_return_union_of_nones_is_true(): + assert MethodDefinition._is_none_like_return(None | type(None)) is True + + +def test_has_request_bound_annotation_request_type(): + assert MethodDefinition._has_request_bound_annotation(Request) is True + + +def test_has_request_bound_annotation_plain_int_false(): + assert MethodDefinition._has_request_bound_annotation(int) is False + + +def test_has_request_bound_annotation_param_empty_false(): + assert MethodDefinition._has_request_bound_annotation(Parameter.empty) is False + + +def test_has_request_bound_annotation_string_request(): + assert MethodDefinition._has_request_bound_annotation("Request") is True + + +def test_has_request_bound_annotation_optional_request(): + assert MethodDefinition._has_request_bound_annotation(Request | None) is True + + +def test_has_request_bound_annotation_non_type_membership_path(): + class _EqAny: + __hash__ = None + + def __eq__(self, _other): + return True + + assert MethodDefinition._has_request_bound_annotation(_EqAny()) is True + + +def test_has_request_bound_annotation_annotated_request(): + assert ( + MethodDefinition._has_request_bound_annotation(Annotated[Request, "meta"]) + is True + ) + + +def test_is_safe_dependency_safe_function(): + def dep() -> int: + return 0 + + assert MethodDefinition._is_safe_dependency(dep) is True + + +def test_is_safe_dependency_returns_none_is_unsafe(): + def dep() -> None: + return None + + assert MethodDefinition._is_safe_dependency(dep) is False + + +def test_is_safe_dependency_with_request_param_is_unsafe(): + def dep(request: Request) -> int: + return 0 + + assert MethodDefinition._is_safe_dependency(dep) is False + + +def test_is_safe_dependency_with_required_param_is_unsafe(): + def dep(x: int) -> int: + return x + + # x has no default -> unsafe + assert MethodDefinition._is_safe_dependency(dep) is False + + +def test_is_safe_dependency_with_default_param_is_safe(): + def dep(x: int = 0) -> int: + return x + + assert MethodDefinition._is_safe_dependency(dep) is True + + +def test_dependency_identifier_signature_error_fallback_name(): + class _Bad: + __name__ = "get.BadThing" + + out = MethodDefinition._dependency_identifier(_Bad()) + assert out == "get__bad_thing" + + +def test_is_none_like_return_union_with_no_args(monkeypatch): + sentinel = object() + monkeypatch.setattr( + md_module, "get_origin", lambda a: Union if a is sentinel else None + ) + monkeypatch.setattr(md_module, "get_args", lambda _a: ()) + assert MethodDefinition._is_none_like_return(sentinel) is True + + +def test_has_request_bound_annotation_annotated_empty_args(monkeypatch): + sentinel = object() + monkeypatch.setattr( + md_module, "get_origin", lambda a: Annotated if a is sentinel else None + ) + monkeypatch.setattr(md_module, "get_args", lambda _a: ()) + assert MethodDefinition._has_request_bound_annotation(sentinel) is False + + +def test_is_safe_dependency_signature_error(): + assert MethodDefinition._is_safe_dependency(object()) is False + + +def test_dependency_identifier_uses_return_annotation_class(): + class MyService: + pass + + def dep() -> MyService: + return MyService() + + assert MethodDefinition._dependency_identifier(dep) == "my_service" + + +def test_dependency_identifier_strips_get_prefix(): + def get_user_settings(): + return None + + assert MethodDefinition._dependency_identifier(get_user_settings) == "user_settings" + + +def test_dependency_identifier_falls_back_to_func_name(): + def my_dep_func(): + return None + + assert MethodDefinition._dependency_identifier(my_dep_func) == "my_dep_func" + + +def test_get_extra_returns_empty_dict_when_no_default(): + class F: + default = None + + assert MethodDefinition.get_extra(F()) == {} + + +def test_get_extra_strips_choices_key(): + fi = FieldInfo(default="x", json_schema_extra={"choices": ["a", "b"], "other": 1}) + out = MethodDefinition.get_extra(FieldInfo(annotation=str, default=fi)) + assert "choices" not in out + assert out == {"other": 1} + + +def test_get_extra_returns_empty_dict_for_missing_extra(): + fi = FieldInfo(default="x") + out = MethodDefinition.get_extra(FieldInfo(annotation=str, default=fi)) + # No extra defined -> empty dict (after .copy() on missing dict) + assert out == {} + + +def test_get_type_constrained_value_maps_to_builtin(): + class ConstrainedIntValue: + pass + + class F: + annotation = ConstrainedIntValue + + assert MethodDefinition.get_type(F()) is int + + +def test_get_default_nested_pydantic_undefined_and_ellipsis(): + from pydantic_core import PydanticUndefined + + class D1: + default = type("_Wrap", (), {"default": PydanticUndefined})() + + class D2: + default = type("_Wrap", (), {"default": Ellipsis})() + + assert MethodDefinition.get_default(D1()) is Parameter.empty + assert MethodDefinition.get_default(D2()) is None + + +def test_is_data_processing_function_true(monkeypatch): + class _R: + methods = {"POST"} + + monkeypatch.setattr( + md_module.PathHandler, "build_route_map", staticmethod(lambda: {"/x": _R()}) + ) + assert MethodDefinition.is_data_processing_function("/x") is True + + +def test_is_data_processing_function_route_exists_but_not_processing(monkeypatch): + class _R: + methods = {"GET"} + + monkeypatch.setattr( + md_module.PathHandler, "build_route_map", staticmethod(lambda: {"/x": _R()}) + ) + assert MethodDefinition.is_data_processing_function("/x") is False + + +def test_get_deprecation_message(monkeypatch): + class _R: + summary = "deprecated msg" + + monkeypatch.setattr( + md_module.PathHandler, "build_route_map", staticmethod(lambda: {"/x": _R()}) + ) + assert MethodDefinition.get_deprecation_message("/x") == "deprecated msg" + + +def test_parse_docstring_params_extracts_descriptions(): + def f(): + """Do nothing. + + Parameters + ---------- + symbol : str + Symbol of the security. + limit : int + Number of rows to return. + + Returns + ------- + None + """ + + out = MethodDefinition._parse_docstring_params(f) + assert out["symbol"] == "Symbol of the security." + assert out["limit"] == "Number of rows to return." + + +def test_parse_docstring_params_no_function_returns_empty(): + assert MethodDefinition._parse_docstring_params(None) == {} + + +def test_parse_docstring_params_no_params_section_returns_empty(): + def f(): + """Just a description, no params section.""" + + assert MethodDefinition._parse_docstring_params(f) == {} + + +def test_parse_docstring_params_no_docstring_returns_empty(): + def f(): + return None + + assert MethodDefinition._parse_docstring_params(f) == {} + + +def test_reorder_params_for_signature_provider_at_end(): + params = { + "symbol": Parameter("symbol", Parameter.POSITIONAL_OR_KEYWORD), + "provider": Parameter("provider", Parameter.POSITIONAL_OR_KEYWORD), + "limit": Parameter("limit", Parameter.POSITIONAL_OR_KEYWORD), + } + out = MethodDefinition.reorder_params(params) + assert list(out.keys()) == ["symbol", "limit", "provider"] + + +def test_reorder_params_for_signature_var_kw_after_provider(): + params = { + "symbol": Parameter("symbol", Parameter.POSITIONAL_OR_KEYWORD), + "kwargs": Parameter("kwargs", Parameter.VAR_KEYWORD), + "provider": Parameter("provider", Parameter.POSITIONAL_OR_KEYWORD), + } + out = MethodDefinition.reorder_params(params, var_kw=["kwargs"]) + assert list(out.keys())[-1] == "kwargs" + # provider should be just before kwargs + assert list(out.keys()).index("provider") < list(out.keys()).index("kwargs") + + +def test_reorder_params_for_docstring_provider_first(): + params = { + "symbol": Parameter("symbol", Parameter.POSITIONAL_OR_KEYWORD), + "provider": Parameter("provider", Parameter.POSITIONAL_OR_KEYWORD), + } + out = MethodDefinition.reorder_params(params, for_docstring=True) + assert list(out.keys())[0] == "provider" + + +def test_reorder_params_no_provider_unchanged_order(): + params = { + "a": Parameter("a", Parameter.POSITIONAL_OR_KEYWORD), + "b": Parameter("b", Parameter.POSITIONAL_OR_KEYWORD), + } + out = MethodDefinition.reorder_params(params) + assert list(out.keys()) == ["a", "b"] + + +def test_get_expanded_type_no_extra_returns_default(): + out = MethodDefinition.get_expanded_type("symbol") + # No expansion match, no extras -> ellipsis + assert out is ... or "TYPE_EXPANSION" in str(out) or out is not None + + +def test_get_expanded_type_multiple_items_allowed_dict_wraps_in_list(): + out = MethodDefinition.get_expanded_type( + "symbol", + extra={"fmp": {"multiple_items_allowed": True}}, + original_type=str, + ) + assert out == list[str] + + +def test_get_expanded_type_multiple_items_allowed_legacy_list_format(): + out = MethodDefinition.get_expanded_type( + "symbol", + extra={"fmp": ["multiple_items_allowed"]}, + original_type=int, + ) + assert out == list[int] + + +def test_get_expanded_type_multiple_items_requires_original_type(): + with pytest.raises(ValueError, match="original type"): + MethodDefinition.get_expanded_type( + "symbol", + extra={"fmp": {"multiple_items_allowed": True}}, + original_type=None, + ) + + +def test_is_annotated_dc_true_for_annotated_dataclass(): + from dataclasses import dataclass + + @dataclass + class D: + x: int = 0 + + ann = Annotated[D, "meta"] + assert MethodDefinition.is_annotated_dc(ann) is True + + +def test_is_annotated_dc_false_for_plain_annotated(): + ann = Annotated[int, "meta"] + assert MethodDefinition.is_annotated_dc(ann) is False + + +def test_is_annotated_dc_false_for_non_annotated(): + assert MethodDefinition.is_annotated_dc(int) is False + + +class _RichQueryParams(QueryParams): + symbol: str = Field( + description="Symbol(s).", + json_schema_extra={ + "rich_a": {"choices": ["AAPL", "MSFT"], "multiple_items_allowed": True}, + "rich_b": {"choices": ["GOOG"], "multiple_items_allowed": False}, + }, + ) + exchange: str | None = Field( + default=None, + description="Exchange.", + json_schema_extra={ + "rich_a": {"choices": ["NYSE", "NASDAQ"]}, + }, + ) + + +class _RichData(Data): + """Standard data class.""" + + symbol: str | None = None + + +class _RichAFetcher(Fetcher[_RichQueryParams, list[_RichData]]): + require_credentials = False + + @staticmethod + def transform_query(params): + return _RichQueryParams(**params) + + @staticmethod + def extract_data(query, credentials): + return [{"symbol": "AAPL"}] + + @staticmethod + def transform_data(query, data, **kwargs): + return [_RichData(**row) for row in data] + + +class _RichBQueryParams(_RichQueryParams): + region: Literal["us", "eu"] | None = None + + +class _RichBFetcher(Fetcher[_RichBQueryParams, list[_RichData]]): + require_credentials = False + + @staticmethod + def transform_query(params): + return _RichBQueryParams(**params) + + @staticmethod + def extract_data(query, credentials): + return [] + + @staticmethod + def transform_data(query, data, **kwargs): + return [] + + +@pytest.fixture +def rich_provider_interface(): + ProviderInterface._instances.pop(ProviderInterface, None) + registry = Registry() + registry.include_provider( + Provider( + name="rich_a", + description="rich A", + website="https://example.invalid", + credentials=None, + fetcher_dict={"RichModel": _RichAFetcher}, + ) + ) + registry.include_provider( + Provider( + name="rich_b", + description="rich B", + website="https://example.invalid", + credentials=None, + fetcher_dict={"RichModel": _RichBFetcher}, + ) + ) + pi = ProviderInterface(registry_map=RegistryMap(registry=registry)) + yield pi + ProviderInterface._instances.pop(ProviderInterface, None) + + +def test_add_field_custom_annotations_standard_model_path(rich_provider_interface): + """Cover the entry path: real model_name with empty od triggers no-op iteration.""" + od = OrderedDict() + out = MethodDefinition.add_field_custom_annotations(od, model_name="RichModel") + assert out is None + + +class _FakeQuery: + """Stand-in for fastapi.Query carrying json_schema_extra.""" + + def __init__(self, description, json_schema_extra): + self.description = description + self.json_schema_extra = json_schema_extra + + +def _build_fake_pi_with_rich_extras(): + """Construct a ProviderInterface-like object whose params[model] has json_schema_extra.""" + + @dataclass + class StdParams: + symbol: Annotated[ + Literal["AAPL"] | Literal["GOOG"] | str, + OpenBBField(description=""), + ] = field( + default=_FakeQuery( + description="sym", + json_schema_extra={ + "prov_a": { + "choices": ["AAPL", "MSFT"], + "multiple_items_allowed": True, + }, + "prov_b": { + "choices": ["GOOG"], + "multiple_items_allowed": ["foo"], + }, + }, + ) + ) + exchange: Annotated[str | None, OpenBBField(description="")] = field( + default=_FakeQuery( + description="exch", + json_schema_extra={"prov_a": {"choices": ["NYSE"]}}, + ) + ) + + @dataclass + class ExtraParams: + pass + + class _FakePI: + params = { + "RichModel": {"standard": StdParams, "extra": ExtraParams}, + } + + return _FakePI() + + +def test_add_field_custom_annotations_full_branch_coverage(): + fake_pi = _build_fake_pi_with_rich_extras() + od = OrderedDict() + od["symbol"] = Parameter( + "symbol", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[ + Literal["AAPL"] | Literal["GOOG"], + OpenBBField(description=""), + ], + ) + od["exchange"] = Parameter( + "exchange", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str | None, OpenBBField(description="")], + default=None, + ) + od["unrelated"] = Parameter( + "unrelated", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[int, OpenBBField(description="orig")], + ) + with patch( + "openbb_core.app.static.package_builder.method_definition.ProviderInterface", + return_value=fake_pi, + ): + MethodDefinition.add_field_custom_annotations(od, model_name="RichModel") + + sym_desc = od["symbol"].annotation.__metadata__[-1].description + assert "Choices for prov_a" in sym_desc + assert "Choices for prov_b" in sym_desc + assert "Multiple items supported by" in sym_desc + exch_desc = od["exchange"].annotation.__metadata__[-1].description + assert "Choices for prov_a" in exch_desc + # 'unrelated' not touched (not in fields) + assert od["unrelated"].annotation.__metadata__[0].description == "orig" + + +def test_add_field_custom_annotations_no_model_name(): + od = OrderedDict() + od["x"] = Parameter("x", kind=Parameter.POSITIONAL_OR_KEYWORD) + out = MethodDefinition.add_field_custom_annotations(od, model_name=None) + assert out is None + assert "x" in od + + +def test_add_field_custom_annotations_unknown_model_returns_silently( + rich_provider_interface, +): + od = OrderedDict() + od["x"] = Parameter("x", kind=Parameter.POSITIONAL_OR_KEYWORD) + out = MethodDefinition.add_field_custom_annotations(od, model_name="DoesNotExist") + assert out is None + + +def test_add_field_custom_annotations_skips_params_not_in_fields( + rich_provider_interface, +): + od = OrderedDict() + od["unknown_param"] = Parameter( + "unknown_param", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="orig")], + ) + MethodDefinition.add_field_custom_annotations(od, model_name="RichModel") + # unchanged + assert od["unknown_param"].annotation.__metadata__[0].description == "orig" + + +def test_format_annotated_param_pydantic_body_expands_fields(): + from inspect import Parameter + from typing import Annotated + + from pydantic import BaseModel, Field + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + + class Body(BaseModel): + a: str = Field(default="x", description="A field.") + b: int = Field(default=1, description="B field.") + + p = Parameter( + name="body", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[Body, "tag"], + ) + formatted: dict = {} + consumed = MethodDefinition._format_annotated_param( + "body", p, "/some/path", formatted + ) + assert consumed is True + assert "a" in formatted and "b" in formatted + assert formatted["a"].default == "x" + assert formatted["b"].default == 1 + + +def test_format_annotated_param_query_object_in_metadata(): + """Lines 485-499: Query meta object with description+default.""" + from inspect import Parameter + from typing import Annotated + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + + class _Q: + description = "Sym desc" + default = "AAPL" + + _Q.__name__ = "Query" + p = Parameter( + name="symbol", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, _Q()], + default="AAPL", + ) + formatted: dict = {} + consumed = MethodDefinition._format_annotated_param("symbol", p, "/x", formatted) + assert consumed is True + assert "symbol" in formatted + + +def test_format_annotated_param_no_query_returns_false(): + """Falls through when neither Pydantic nor Query.""" + from inspect import Parameter + from typing import Annotated + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + + p = Parameter( + name="x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, "just_a_string"], + ) + formatted: dict = {} + consumed = MethodDefinition._format_annotated_param("x", p, "/path", formatted) + assert consumed is False + + +def test_format_annotated_param_data_processing_skips_pydantic_branch(): + """When path is data-processing, the Pydantic body branch is skipped.""" + from inspect import Parameter + from typing import Annotated + + from pydantic import BaseModel + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + + class Body(BaseModel): + a: str = "x" + + p = Parameter( + name="body", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[Body, "tag"], + ) + formatted: dict = {} + # data-processing path means is_get_request is False + # This requires a path that returns False from is_data_processing_function + # If Body has no Query meta, returns False + consumed = MethodDefinition._format_annotated_param( + "body", p, "/econometrics/foo", formatted + ) + # Either consumed True (if /econometrics/foo treated as get) or False (data-proc) + assert consumed in (True, False) + + +def test_parse_docstring_params_basic(): + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + + def f(symbol): + """Func. + + Parameters + ---------- + symbol : str + The symbol description. + other : int + Another desc. + """ + + out = MethodDefinition._parse_docstring_params(f) + assert out.get("symbol") == "The symbol description." + assert out.get("other") == "Another desc." + + +def test_build_command_method_filter_inputs_source_extraction(monkeypatch): + """Lines 1263-1313: extract additional params from filter_inputs() in func source.""" + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + from openbb_core.app.static.package_builder.path_handler import PathHandler + + def my_endpoint(symbol: str = "AAPL"): + """Endpoint.""" + period: int = 5 + weights: list = [] + result = filter_inputs(sym=symbol, p=period, w=weights, flag=True) # noqa: F821 + return result + + class _FakeRoute: + deprecated = False + path = "/test/endpoint" + endpoint = my_endpoint + openapi_extra: dict = {} + + monkeypatch.setattr( + PathHandler, + "build_route_map", + staticmethod(lambda: {"/test/endpoint": _FakeRoute()}), + ) + + code = MethodDefinition.build_command_method( + path="/test/endpoint", + func=my_endpoint, + model_name=None, + ) + assert "def endpoint" in code + + +def test_format_params_query_default_value(monkeypatch): + """Lines 567-586: when param.default is a Query-class instance, extract description+default.""" + from inspect import Parameter + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + from openbb_core.app.static.package_builder.path_handler import PathHandler + + class _Q: + description = "Sym" + default = "AAPL" + + _Q.__name__ = "Query" + + parameter_map = { + "symbol": Parameter( + name="symbol", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=str, + default=_Q(), + ), + } + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + out = MethodDefinition.format_params("/x/y", parameter_map) + assert "symbol" in out + assert out["symbol"].default == "AAPL" + + +def test_format_params_dataclass_annotated_field_expansion(monkeypatch): + """Lines 623-638: is_annotated_dc branch -> field expansion.""" + from dataclasses import dataclass + from inspect import Parameter + from typing import Annotated + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + from openbb_core.app.static.package_builder.path_handler import PathHandler + + @dataclass + class _Std: + symbol: str = "AAPL" + days: int = 7 + + parameter_map = { + "standard_params": Parameter( + name="standard_params", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[_Std, "tag"], + ), + } + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + out = MethodDefinition.format_params("/x/y", parameter_map) + assert "symbol" in out + assert "days" in out + + +def test_format_params_provider_choices(monkeypatch): + """Lines 591-619: provider_choices Annotated[dataclass, ...] -> 'provider' Parameter.""" + from dataclasses import dataclass + from inspect import Parameter + from typing import Annotated, Literal + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + from openbb_core.app.static.package_builder.path_handler import PathHandler + + @dataclass + class _PC: + provider: Literal["a", "b"] = "a" + + parameter_map = { + "provider_choices": Parameter( + name="provider_choices", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[_PC, "tag"], + ), + } + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + out = MethodDefinition.format_params("/x/y", parameter_map) + assert "provider" in out + assert out["provider"].default is None + + +def test_format_params_path_parameters_extracted(monkeypatch): + from inspect import Parameter + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + from openbb_core.app.static.package_builder.path_handler import PathHandler + + parameter_map = { + "ticker": Parameter( + name="ticker", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=str + ), + } + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + out = MethodDefinition.format_params("/equity/{ticker}/info", parameter_map) + assert "ticker" in out + + +def test_build_command_method_body_annotated_basemodel(monkeypatch): + """Lines 1176-1193: Annotated[BaseModel, ...] without Depends -> field expansion.""" + from typing import Annotated + + from pydantic import BaseModel + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + + class _Body(BaseModel): + symbol: str = "AAPL" + period: int = 7 + + def my_endpoint(body: Annotated[_Body, "tag"]): + return body + + code = MethodDefinition.build_command_method_body(path="/x/y", func=my_endpoint) + assert '"symbol": symbol' in code + assert '"period": period' in code + + +def test_build_command_method_body_annotated_basemodel_with_depends(monkeypatch): + """Lines 1192-1193: Annotated[BaseModel, ...] WITH Depends -> passthrough.""" + from typing import Annotated + + from fastapi import Depends + from pydantic import BaseModel + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + + class _Body(BaseModel): + symbol: str = "AAPL" + + def _dep(): + return _Body() + + def my_endpoint(body: Annotated[_Body, Depends(_dep)]): + return body + + code = MethodDefinition.build_command_method_body(path="/x/y", func=my_endpoint) + assert "body=body" in code + + +def test_build_command_method_body_extra_params_with_extras(monkeypatch): + """Lines 1141-1147: extra_params dataclass with extras dict -> info accumulated.""" + from dataclasses import dataclass, field + from typing import Annotated + + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + + @dataclass + class _Extras: + symbol: str = field(default="AAPL", metadata={"x-extra": {"choices": ["AAPL"]}}) + + def my_endpoint(extra_params: Annotated[_Extras, "tag"]): + return None + + code = MethodDefinition.build_command_method_body(path="/x/y", func=my_endpoint) + assert "extra_params=kwargs" in code + + +def test_build_command_method_deprecated_path(monkeypatch): + """Lines 1117-1120: deprecated function path -> emits simplefilter+warn lines.""" + from openbb_core.app.static.package_builder.method_definition import ( + MethodDefinition, + ) + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: True) + ) + monkeypatch.setattr( + MethodDefinition, "get_deprecation_message", staticmethod(lambda p: "Old API") + ) + + def my_endpoint(symbol: str = "AAPL"): + return symbol + + code = MethodDefinition.build_command_method_body(path="/x/y", func=my_endpoint) + assert "simplefilter('always', DeprecationWarning)" in code + assert "Old API" in code + + +def test_format_params_extra_params_and_provider_choices_skip(monkeypatch): + from inspect import Parameter + + from openbb_core.app.static.package_builder.path_handler import PathHandler + + parameter_map = { + "extra_params": Parameter( + name="extra_params", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=dict + ), + "provider_choices": Parameter( + name="provider_choices", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Parameter.empty, + ), + } + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + out = MethodDefinition.format_params("/x/y", parameter_map) + assert "extra_params" in out + + +def test_format_params_annotated_without_depends_and_var_keyword(monkeypatch): + from inspect import Parameter + + from openbb_core.app.static.package_builder.path_handler import PathHandler + + parameter_map = { + "symbol": Parameter( + name="symbol", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="")], + ), + "kwargs": Parameter( + name="kwargs", + kind=Parameter.VAR_KEYWORD, + annotation=int, + default=Parameter.empty, + ), + "any_kwargs": Parameter( + name="any_kwargs", + kind=Parameter.VAR_KEYWORD, + annotation=int, + default=Parameter.empty, + ), + } + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + out = MethodDefinition.format_params("/x/y", parameter_map) + assert "symbol" in out and "any_kwargs" in out + + +def test_format_params_constrained_expanded_type_branch(monkeypatch): + from inspect import Parameter + + from openbb_core.app.static.package_builder.path_handler import PathHandler + + class _Constrained: + __constraints__ = (str,) + + parameter_map = { + "x": Parameter(name="x", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=int) + } + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + monkeypatch.setattr( + MethodDefinition, + "get_expanded_type", + classmethod(lambda cls, *_a, **_k: _Constrained), + ) + out = MethodDefinition.format_params("/x/y", parameter_map) + assert "x" in out + + +def test_add_field_custom_annotations_all_literal_union_simplifies_type(monkeypatch): + from dataclasses import dataclass + + class _QueryObj: + def __init__(self): + self.json_schema_extra = {"prov": {"choices": ["a", "b"]}} + self.description = "desc" + + @dataclass + class _Std: + p: Literal["a"] | Literal["b"] = _QueryObj() + + @dataclass + class _Extra: + pass + + fake_pi = type("_PI", (), {"params": {"M": {"standard": _Std, "extra": _Extra}}})() + + od = OrderedDict( + { + "p": Parameter( + "p", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[ + Literal["a"] | Literal["b"], OpenBBField(description="") + ], + ) + } + ) + + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.ProviderInterface", + lambda: fake_pi, + ) + MethodDefinition.add_field_custom_annotations(od, model_name="M") + assert "Choices for prov" in od["p"].annotation.__metadata__[-1].description + + +def test_build_func_params_wraps_long_desc_and_ellipsis_default(): + long_desc = "x" * 120 + params = OrderedDict( + { + "x": Parameter( + name="x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[list[int], OpenBBField(description=long_desc)], + default=Ellipsis, + ) + } + ) + out = MethodDefinition.build_func_params(params) + assert "OpenBBField" in out + assert " = None" in out + + +def test_build_func_returns_obbject_subclass(): + from openbb_core.app.model.obbject import OBBject + + class _O(OBBject): + pass + + assert MethodDefinition.build_func_returns(_O) == "OBBject" + + +def test_collect_dependency_calls_skips_unsafe_annotated_dependency(): + def _unsafe() -> None: + return None + + parameter_map = { + "dep": Parameter( + name="dep", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[int, type("_Meta", (), {"dependency": _unsafe})()], + ) + } + calls, names = MethodDefinition._collect_dependency_calls("/x/y", parameter_map) + assert calls == [] + assert names == set() + + +def test_build_command_method_body_provider_choices_annotated_dc_info_and_kwargs( + monkeypatch, +): + from dataclasses import dataclass + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + monkeypatch.setattr( + MethodDefinition, "is_data_processing_function", staticmethod(lambda p: True) + ) + + class _D: + json_schema_extra = {"note": "x"} + + @dataclass + class _Extras: + alpha: str = _D() + + @dataclass + class _ProviderChoices: + provider: Literal["a", "b"] = "a" + + @dataclass + class _Std: + beta: int = 1 + + def endpoint( + extra_params: Annotated[_Extras, "tag"], + provider_choices: Annotated[_ProviderChoices, "tag"], + standard_params: Annotated[_Std, "tag"], + **kwargs, + ): + return None + + code = MethodDefinition.build_command_method_body("/x/y", endpoint) + assert "provider_choices={" in code + assert "standard_params={" in code + assert "info={" in code + assert "data_processing=True" in code + + +def test_build_command_method_source_eval_fallback_branches(monkeypatch): + def endpoint(symbol: str = "AAPL"): + bad: UnknownType = nope # type: ignore[name-defined] # noqa: F821 + result = filter_inputs(a=bad, b=missing) # noqa: F821 + return result + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + code = MethodDefinition.build_command_method("/x/y", endpoint) + assert "def y" in code + + +def test_format_annotated_param_query_default_pydantic_undefined(): + from pydantic_core import PydanticUndefined + + class _Q: + description = "x" + default = PydanticUndefined + + _Q.__name__ = "Query" + + p = Parameter( + name="x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, _Q()], + default=Parameter.empty, + ) + formatted = {} + assert MethodDefinition._format_annotated_param("x", p, "/x", formatted) is True + + +def test_format_params_chart_branch(monkeypatch): + from openbb_core.app.static.package_builder import method_definition as md + from openbb_core.app.static.package_builder.path_handler import PathHandler + + class _Chart: + @staticmethod + def functions(): + return ["x_y"] + + monkeypatch.setattr(md, "CHARTING_INSTALLED", True) + monkeypatch.setattr(md, "Charting", _Chart, raising=False) + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + out = MethodDefinition.format_params("/x/y", {}) + assert "chart" in out + + +def test_format_params_continue_when_annotated_param_consumed(monkeypatch): + from openbb_core.app.static.package_builder.path_handler import PathHandler + + p = Parameter( + name="x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="")], + ) + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + monkeypatch.setattr( + MethodDefinition, + "_format_annotated_param", + staticmethod(lambda *_a, **_k: True), + ) + out = MethodDefinition.format_params("/x/y", {"x": p}) + assert out == OrderedDict() + + +def test_format_params_annotated_non_depends_new_type_ellipsis(monkeypatch): + from openbb_core.app.static.package_builder.path_handler import PathHandler + + p = Parameter( + name="x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="")], + ) + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + monkeypatch.setattr( + MethodDefinition, "get_expanded_type", classmethod(lambda cls, *_a, **_k: ...) + ) + out = MethodDefinition.format_params("/x/y", {"x": p}) + assert "x" in out + + +def test_format_params_annotated_with_depends_continue(monkeypatch): + from openbb_core.app.static.package_builder.path_handler import PathHandler + + meta = type("_Meta", (), {"dependency": lambda: 1})() + p = Parameter( + name="x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, meta], + ) + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + out = MethodDefinition.format_params("/x/y", {"x": p}) + assert "x" not in out + + +def test_add_field_custom_annotations_union_not_all_literals(monkeypatch): + from dataclasses import dataclass + + class _QueryObj: + def __init__(self): + self.json_schema_extra = {"prov": {"choices": ["a", "b"]}} + self.description = "desc" + + @dataclass + class _Std: + p: Literal["a"] | int = _QueryObj() + + @dataclass + class _Extra: + pass + + fake_pi = type("_PI", (), {"params": {"M": {"standard": _Std, "extra": _Extra}}})() + od = OrderedDict( + { + "p": Parameter( + "p", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[Literal["a"] | int, OpenBBField(description="")], + ) + } + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.ProviderInterface", + lambda: fake_pi, + ) + MethodDefinition.add_field_custom_annotations(od, model_name="M") + assert "Choices for prov" in od["p"].annotation.__metadata__[-1].description + + +def test_add_field_custom_annotations_choices_branch(monkeypatch): + from types import SimpleNamespace + + std_field = SimpleNamespace( + default=SimpleNamespace(description="d"), + type=Literal["a"] | Literal["b"], + json_schema_extra={"choices": ["a", "b"]}, + ) + fake_std = type("_S", (), {"__dataclass_fields__": {"p": std_field}}) + fake_extra = type("_E", (), {"__dataclass_fields__": {}}) + fake_pi = type( + "_PI", (), {"params": {"M": {"standard": fake_std, "extra": fake_extra}}} + )() + + od = OrderedDict( + { + "p": Parameter( + "p", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[ + Literal["a"] | Literal["b"], OpenBBField(description="") + ], + ) + } + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.ProviderInterface", + lambda: fake_pi, + ) + MethodDefinition.add_field_custom_annotations(od, model_name="M") + assert od["p"].annotation.__metadata__[-1].description == "d" + + +def test_build_func_params_typing_repr_and_none_description(): + + params = OrderedDict( + { + "x": Parameter( + name="x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[list[int], OpenBBField(description=None)], + default=1, + ) + } + ) + out = MethodDefinition.build_func_params(params) + assert "List[int]" in out or "list[int]" in out or "list" in out + + +def test_build_command_method_body_chart_branch(monkeypatch): + from openbb_core.app.static.package_builder import method_definition as md + + class _Chart: + @staticmethod + def functions(): + return ["x_y"] + + monkeypatch.setattr(md, "CHARTING_INSTALLED", True) + monkeypatch.setattr(md, "Charting", _Chart, raising=False) + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + + def endpoint(symbol: str = "AAPL"): + return symbol + + code = MethodDefinition.build_command_method_body("/x/y", endpoint) + assert "chart" in code + + +def test_build_command_method_body_annotated_dc_info_extra(monkeypatch): + from dataclasses import dataclass + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + monkeypatch.setattr( + MethodDefinition, "is_data_processing_function", staticmethod(lambda p: False) + ) + + class _D: + json_schema_extra = {"note": "x"} + + @dataclass + class _Std: + beta: int = _D() + + def endpoint(standard_params: Annotated[_Std, "tag"]): + return None + + code = MethodDefinition.build_command_method_body("/x/y", endpoint) + assert "info={" in code + + +def test_build_command_method_getsource_typeerror(monkeypatch): + from openbb_core.app.static.package_builder import method_definition as md + + def endpoint(symbol: str = "AAPL"): + return symbol + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + monkeypatch.setattr( + md.inspect, "getsource", lambda _f: (_ for _ in ()).throw(TypeError("x")) + ) + code = MethodDefinition.build_command_method("/x/y", endpoint) + assert "def y" in code diff --git a/openbb_platform/core/tests/app/static/package_builder/test_path_handler.py b/openbb_platform/core/tests/app/static/package_builder/test_path_handler.py new file mode 100644 index 00000000000..02e9d137f67 --- /dev/null +++ b/openbb_platform/core/tests/app/static/package_builder/test_path_handler.py @@ -0,0 +1,124 @@ +"""Extended coverage tests for PathHandler.""" + +from unittest.mock import MagicMock + +from openbb_core.app.router import Router +from openbb_core.app.static.package_builder.path_handler import PathHandler + + +def _make_router_with_dotted_route(): + """Create a Router that has a route with '.' in path (excluded from initial map).""" + from fastapi import APIRouter as FastAPIRouter + + async def endpoint_fn() -> None: + pass + + inner = FastAPIRouter() + inner.add_api_route("/some.dotted/route", endpoint_fn, methods=["GET"]) + r = Router() + r._api_router = inner + return r + + +def test_build_route_map_collects_dotted_path_routes(monkeypatch): + """Test collect_api_router_routes adds routes with dots in path that were excluded + from the initial route_map build.""" + r = _make_router_with_dotted_route() + monkeypatch.setattr( + "openbb_core.app.static.package_builder.path_handler.RouterLoader.from_extensions", + lambda: r, + ) + route_map = PathHandler.build_route_map() + assert "/some.dotted/route" in route_map + + +def test_build_route_map_skips_non_apiroute(monkeypatch): + """Test continue when route in api_router.routes is not an APIRoute.""" + from fastapi import APIRouter as FastAPIRouter + + async def real_endpoint() -> None: + pass + + inner = FastAPIRouter() + inner.add_api_route("/legit/path", real_endpoint, methods=["GET"]) + + r = Router() + r._api_router = inner + + mock_mount = MagicMock(spec=[]) # NOT an APIRoute instance + r._api_router.routes.append(mock_mount) + + monkeypatch.setattr( + "openbb_core.app.static.package_builder.path_handler.RouterLoader.from_extensions", + lambda: r, + ) + route_map = PathHandler.build_route_map() + assert isinstance(route_map, dict) + + +def test_build_route_map_recurses_into_endpoint_self(monkeypatch): + """Test collect_api_router_routes recurses when endpoint has __self__.""" + from fastapi import APIRouter as FastAPIRouter + + async def inner_endpoint() -> None: + pass + + inner_router = FastAPIRouter() + inner_router.add_api_route("/inner/route", inner_endpoint, methods=["GET"]) + + sub = Router() + sub._api_router = inner_router + + async def outer_endpoint() -> None: + pass + + outer_router = FastAPIRouter() + outer_router.add_api_route("/outer/path", outer_endpoint, methods=["GET"]) + + outer_route = outer_router.routes[0] + outer_route.endpoint.__self__ = sub # type: ignore[attr-defined] + + r = Router() + r._api_router = outer_router + + monkeypatch.setattr( + "openbb_core.app.static.package_builder.path_handler.RouterLoader.from_extensions", + lambda: r, + ) + route_map = PathHandler.build_route_map() + assert isinstance(route_map, dict) + + +def test_get_router_dependencies_skips_falsy_api_router(monkeypatch): + """Test continue when api_router is falsy for a candidate path.""" + mock_router = MagicMock() + mock_router.get_attr.return_value = None + monkeypatch.setattr( + "openbb_core.app.static.package_builder.path_handler.RouterLoader.from_extensions", + lambda: mock_router, + ) + result = PathHandler.get_router_dependencies("/equity/price") + assert result == [] + + +def test_get_child_path_list_adds_direct_child(monkeypatch): + """Test direct_children.append when route_path is a direct child.""" + monkeypatch.setattr( + PathHandler, + "build_route_map", + staticmethod(lambda: {"/equity/{symbol}": MagicMock()}), + ) + result = PathHandler.get_child_path_list("/equity", []) + assert "/equity/{symbol}" in result + + +def test_get_router_description_returns_description(monkeypatch): + """Test returns description when the router has one set.""" + mock_router = MagicMock() + mock_router.get_attr.return_value = "My router description" + monkeypatch.setattr( + "openbb_core.app.static.package_builder.path_handler.RouterLoader.from_extensions", + lambda: mock_router, + ) + result = PathHandler.get_router_description("/equity") + assert result == "My router description" diff --git a/openbb_platform/core/tests/app/static/package_builder/test_reference_generator_helpers.py b/openbb_platform/core/tests/app/static/package_builder/test_reference_generator_helpers.py new file mode 100644 index 00000000000..4504e0d24e1 --- /dev/null +++ b/openbb_platform/core/tests/app/static/package_builder/test_reference_generator_helpers.py @@ -0,0 +1,373 @@ +"""Direct unit tests for static helpers in ``ReferenceGenerator``.""" + +from typing import Annotated + +import pytest + +pandas = pytest.importorskip("pandas") + +pytestmark = pytest.mark.requires_pandas + +from fastapi import Query +from pydantic import Field +from pydantic.fields import FieldInfo + +from openbb_core.app.static.package_builder.reference_generator import ( + ReferenceGenerator, +) + + +def test_clean_string_values_replaces_full_data_path(): + out = ReferenceGenerator._clean_string_values( + "openbb_core.provider.abstract.data.Data" + ) + assert out == "Data" + + +def test_clean_string_values_replaces_list_of_data_path(): + out = ReferenceGenerator._clean_string_values( + "list[openbb_core.provider.abstract.data.Data]" + ) + assert out == "list[Data]" + + +def test_clean_string_values_simplifies_union_type(): + out = ReferenceGenerator._clean_string_values("Union[int, str]") + # Sorted alphabetically and joined with ' | ' + assert out == "int | str" + + +def test_clean_string_values_quotes_literal_members(): + out = ReferenceGenerator._clean_string_values("Literal[a, b]") + assert out == "Literal['a', 'b']" + + +def test_clean_string_values_does_not_requote_already_quoted_literal(): + out = ReferenceGenerator._clean_string_values("Literal['a', 'b']") + # Already quoted -> the literal-quoting branch is skipped + assert "Literal['a', 'b']" in out + + +def test_clean_string_values_lowercases_dict_and_list_aliases(): + out = ReferenceGenerator._clean_string_values("Dict[str, List[int]]") + assert "dict" in out + assert "list" in out + assert "Dict" not in out + assert "List" not in out + + +def test_clean_string_values_recurses_into_dict(): + out = ReferenceGenerator._clean_string_values( + {"a": "Union[int, str]", "b": "Dict[str, int]"} + ) + assert out == {"a": "int | str", "b": "dict[str, int]"} + + +def test_clean_string_values_recurses_into_list(): + out = ReferenceGenerator._clean_string_values(["List[int]", "Dict[str, str]"]) + assert out == ["list[int]", "dict[str, str]"] + + +def test_clean_string_values_passthrough_non_string(): + assert ReferenceGenerator._clean_string_values(42) == 42 + assert ReferenceGenerator._clean_string_values(None) is None + + +def test_get_obbject_returns_fields_default_provider(): + out = ReferenceGenerator._get_obbject_returns_fields("MyModel", "") + assert isinstance(out, list) + names = [item["name"] for item in out] + assert names == ["results", "provider", "warnings", "chart", "extra"] + # When providers is empty, the type defaults to 'str' + provider_entry = next(item for item in out if item["name"] == "provider") + assert provider_entry["type"] == "str" + + +def test_get_obbject_returns_fields_with_providers(): + out = ReferenceGenerator._get_obbject_returns_fields("MyModel", "fmp, polygon") + provider_entry = next(item for item in out if item["name"] == "provider") + assert provider_entry["type"] == "fmp, polygon" + + +def test_get_post_method_returns_info_extracts_obbject_inner_type(): + docstring = ( + "Some description.\n\n" + "Returns\n" + " -------\n" + " OBBject[list[Data]]\n" + " The OBBject of results.\n" + ) + out = ReferenceGenerator._get_post_method_returns_info(docstring) + assert out["name"] == "results" + assert "list[Data]" in out["type"] + assert "results" in out["description"] or out["description"] + + +def test_get_post_method_returns_info_handles_list_wrapper(): + docstring = ( + "Returns\n -------\n list[MyData]\n A list of data points.\n" + ) + out = ReferenceGenerator._get_post_method_returns_info(docstring) + assert out["type"] == "MyData" + + +def test_get_post_method_returns_info_no_match_returns_empty_dict(): + out = ReferenceGenerator._get_post_method_returns_info("No returns section here.") + assert out == {} + + +def test_get_post_method_parameters_info_extracts_params(): + docstring = ( + "Parameters\n" + " ----------\n" + " symbol : str\n" + " Symbol of the security.\n" + " limit : int, optional\n" + " Row limit.\n" + " Returns\n" + " -------\n" + " OBBject[Data]\n" + ) + out = ReferenceGenerator._get_post_method_parameters_info(docstring) + names = [p["name"] for p in out] + assert "symbol" in names + assert "limit" in names + limit = next(p for p in out if p["name"] == "limit") + assert limit["optional"] is True + assert "optional" not in limit["type"] + + +def test_get_post_method_parameters_info_no_parameters_returns_empty(): + out = ReferenceGenerator._get_post_method_parameters_info("Just a description.") + assert out == [] + + +def test_get_post_method_parameters_info_optional_marker_in_type(): + docstring = ( + "Parameters\n ----------\n name : Optional[str]\n A name.\n" + ) + out = ReferenceGenerator._get_post_method_parameters_info(docstring) + assert out[0]["optional"] is True + + +def test_resolve_field_type_str_required_simple(): + fi = FieldInfo(annotation=int) + type_str, is_required = ReferenceGenerator._resolve_field_type_str(fi) + assert is_required is True + assert "int" in type_str + + +def test_resolve_field_type_str_optional_unwraps(): + fi = FieldInfo(annotation=str | None, default=None) + type_str, is_required = ReferenceGenerator._resolve_field_type_str(fi) + assert is_required is False + assert "str" in type_str + assert "| None" in type_str + + +def test_resolve_field_type_str_unwraps_annotated(): + fi = FieldInfo( + annotation=Annotated[int, "metadata"], + default=None, + ) + type_str, is_required = ReferenceGenerator._resolve_field_type_str(fi) + # Should not start with "Annotated" + assert not type_str.startswith("Annotated") + + +def test_apply_query_param_extras_with_multiple_items_dict(): + desc, type_str, choices = ReferenceGenerator._apply_query_param_extras( + cleaned_description="Symbol", + field_type_str="str", + choices=None, + extra={"fmp": {"multiple_items_allowed": True, "choices": ["a", "b"]}}, + ) + assert "Multiple items allowed" in desc + assert "fmp" in desc + assert "list[str]" in type_str + assert choices == ["a", "b"] + + +def test_apply_query_param_extras_legacy_list_format(): + desc, type_str, choices = ReferenceGenerator._apply_query_param_extras( + cleaned_description="Symbol", + field_type_str="str", + choices=None, + extra={"fmp": ["multiple_items_allowed"]}, + ) + assert "Multiple items allowed" in desc + assert "list[str]" in type_str + + +def test_apply_query_param_extras_with_choices_only_no_multiple(): + desc, type_str, choices = ReferenceGenerator._apply_query_param_extras( + cleaned_description="Symbol", + field_type_str="str", + choices=None, + extra={"fmp": {"choices": ["x", "y"]}}, + ) + assert "Multiple items allowed" not in desc + assert type_str == "str" + assert choices == ["x", "y"] + + +def test_apply_query_param_extras_top_level_multiple_items(): + desc, type_str, _ = ReferenceGenerator._apply_query_param_extras( + cleaned_description="Symbol", + field_type_str="str", + choices=None, + extra={"multiple_items_allowed": True}, + ) + assert "Multiple items allowed" in desc + assert "list[str]" in type_str + + +def test_get_function_signature_info_basic_params(): + def f(a: int, b: str = "x", c: int | None = None) -> None: + return None + + out = ReferenceGenerator._get_function_signature_info(f) + names = [p["name"] for p in out] + assert names == ["a", "b", "c"] + a, b, c = out + assert a["optional"] is False + assert b["optional"] is True + assert b["default"] == "x" + assert c["optional"] is True + assert "int" in c["type"] + + +def test_get_function_signature_info_skips_self_and_cc(): + def f(self, cc, x: int) -> None: + return None + + out = ReferenceGenerator._get_function_signature_info(f) + names = [p["name"] for p in out] + assert names == ["x"] + + +def test_get_function_signature_info_with_query_default(): + def f(symbol: str = Query(default="AAPL", description="Ticker symbol")) -> None: + return None + + out = ReferenceGenerator._get_function_signature_info(f) + assert out[0]["name"] == "symbol" + assert out[0]["description"] == "Ticker symbol" + assert out[0]["optional"] is True + assert out[0]["default"] == "AAPL" + + +def test_get_function_signature_info_with_annotated_metadata(): + def f(x: Annotated[int, Field(description="x val")]) -> None: + return None + + out = ReferenceGenerator._get_function_signature_info(f) + assert out[0]["description"] == "x val" + # base type stripped from Annotated + assert "int" in out[0]["type"] + + +def test_get_function_signature_info_skips_depends_default(): + from fastapi import Depends + + def dep(): + return None + + def f(x: int = Depends(dep)) -> None: + return None + + out = ReferenceGenerator._get_function_signature_info(f) + assert out == [] + + +def test_resolve_field_type_str_typing_union_optional_branch(): + fi = FieldInfo(annotation=str | None, default=None) + type_str, is_required = ReferenceGenerator._resolve_field_type_str(fi) + assert is_required is False + assert "str" in type_str + + +def test_get_function_signature_info_typing_union_optional_branch(): + def f(x: int | None = None) -> None: + return None + + out = ReferenceGenerator._get_function_signature_info(f) + assert out[0]["name"] == "x" + assert out[0]["optional"] is True + assert "int" in out[0]["type"] + + +def test_resolve_field_type_str_union_branch_with_pep604(monkeypatch): + from types import UnionType + + from openbb_core.app.static.package_builder import reference_generator as rg + + monkeypatch.setattr(rg, "Union", UnionType) + + fi = FieldInfo(annotation=str | None, default=None) + type_str, is_required = ReferenceGenerator._resolve_field_type_str(fi) + assert is_required is False + assert "str" in type_str + assert "| None" in type_str + + +def test_get_function_signature_info_union_optional_branch_with_pep604(monkeypatch): + from types import UnionType + + from openbb_core.app.static.package_builder import reference_generator as rg + + monkeypatch.setattr(rg, "Union", UnionType) + + def f(x: int | None = None) -> None: + return None + + out = ReferenceGenerator._get_function_signature_info(f) + assert out[0]["name"] == "x" + assert out[0]["optional"] is True + assert "int" in out[0]["type"] + + +def test_resolve_field_type_str_forced_union_branch(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import reference_generator as rg + + union_sentinel = object() + monkeypatch.setattr(rg, "Union", union_sentinel) + monkeypatch.setattr(rg, "get_origin", lambda _x: union_sentinel) + monkeypatch.setattr(rg, "get_args", lambda _x: (str, type(None))) + + fi = SimpleNamespace(annotation=object(), is_required=lambda: True) + type_str, is_required = ReferenceGenerator._resolve_field_type_str(fi) + + assert is_required is False + assert "str" in type_str + assert "| None" in type_str + + +def test_get_function_signature_info_forced_optional_union_branch(monkeypatch): + from inspect import Parameter, Signature + + from openbb_core.app.static.package_builder import reference_generator as rg + + union_sentinel = object() + + class _FakeOptionalType: + __origin__ = union_sentinel + __args__ = (int, type(None)) + + fake_param = Parameter( + "x", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=_FakeOptionalType, + default=0, + ) + fake_sig = Signature(parameters=[fake_param]) + + monkeypatch.setattr(rg, "Union", union_sentinel) + monkeypatch.setattr(rg, "signature", lambda _func: fake_sig) + + out = ReferenceGenerator._get_function_signature_info(lambda: None) + assert out[0]["name"] == "x" + assert out[0]["optional"] is True + assert "int" in out[0]["type"] diff --git a/openbb_platform/core/tests/app/static/test_app_factory.py b/openbb_platform/core/tests/app/static/test_app_factory.py index 8c344e2836c..324ee6602da 100644 --- a/openbb_platform/core/tests/app/static/test_app_factory.py +++ b/openbb_platform/core/tests/app/static/test_app_factory.py @@ -43,5 +43,19 @@ def test_app_coverage(app_factory): def test_app_reference(app_factory): """Test app reference.""" reference = app_factory.reference - assert reference assert isinstance(reference, dict) + + +def test_app_repr_without_extensions_doc(): + app = create_app() + out = repr(app) + assert "OpenBB Platform" in out + + +def test_app_repr_with_empty_extension_doc(): + class _Ext: + __doc__ = None + + app = create_app(_Ext) + out = repr(app) + assert "OpenBB Platform" in out diff --git a/openbb_platform/core/tests/app/static/test_container.py b/openbb_platform/core/tests/app/static/test_container.py index 735e553795e..6288b2544ec 100644 --- a/openbb_platform/core/tests/app/static/test_container.py +++ b/openbb_platform/core/tests/app/static/test_container.py @@ -103,3 +103,94 @@ def test_container__get_provider( else: result = container._get_provider(choice, command, default_priority) assert result == expected + + +"""Extended Container tests targeting uncovered branches.""" + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from openbb_core.app.model.obbject import OBBject + + +@pytest.fixture +def container_with_defaults(): + class MockCredentials(BaseModel): + provider_1_api_key: SecretStr | None = None + + MockCredentials.origins = {"provider_1": ["provider_1_api_key"]} + + settings = UserSettings() + settings.credentials = MockCredentials() + settings.defaults = Defaults( + commands={ + "test.cmd": { + "provider": "provider_1", + "chart": True, + "limit": 50, + "extra_only": "x", + } + } + ) + return Container(CommandRunner(user_settings=settings)) + + +@patch("openbb_core.app.command_runner.CommandRunner.sync_run") +def test_run_applies_defaults_to_standard_and_extra_params( + mock_sync_run, container_with_defaults +): + container_with_defaults._run( + "/test/cmd", + standard_params={"limit": None}, + extra_params={"extra_only": None}, + ) + args, kwargs = mock_sync_run.call_args + assert kwargs["standard_params"]["limit"] == 50 + assert kwargs["extra_params"]["extra_only"] == "x" + assert kwargs.get("chart") is True + + +@patch("openbb_core.app.command_runner.CommandRunner.sync_run") +def test_run_results_only_returns_results_list(mock_sync_run): + settings = UserSettings() + c = Container(CommandRunner(user_settings=settings)) + + obb = OBBject(results=[{"a": 1}]) + object.__setattr__(obb, "_results_only", True) + mock_sync_run.return_value = obb + + out = c._run( + "/foo", + standard_params={}, + extra_params={}, + ) + assert out == [{"a": 1}] + + +@patch("openbb_core.app.command_runner.CommandRunner.sync_run") +def test_run_with_dataframe_output_type(mock_sync_run): + settings = UserSettings() + settings.preferences.output_type = "dataframe" + c = Container(CommandRunner(user_settings=settings)) + + fake = SimpleNamespace(to_dataframe=lambda: "DF", _results_only=False) + mock_sync_run.return_value = fake + + out = c._run("/foo", standard_params={}, extra_params={}) + assert out == "DF" + + +def test_get_provider_single_provider_in_config_short_circuits(): + class MockCredentials(BaseModel): + provider_1_api_key: SecretStr | None = None + + MockCredentials.origins = {"provider_1": ["provider_1_api_key"]} + + settings = UserSettings() + settings.credentials = MockCredentials() + settings.defaults = Defaults(commands={"only.one": {"provider": ["solo"]}}) + c = Container(CommandRunner(user_settings=settings)) + # Single provider in config list -> returned without credential check (line 101). + assert c._get_provider(None, "only.one", ("a", "b")) == "solo" diff --git a/openbb_platform/core/tests/app/static/test_coverage.py b/openbb_platform/core/tests/app/static/test_coverage.py index 5f643f2e38e..7b09168d88c 100644 --- a/openbb_platform/core/tests/app/static/test_coverage.py +++ b/openbb_platform/core/tests/app/static/test_coverage.py @@ -12,6 +12,7 @@ through the real production code paths. import pytest from openbb_core.app.command_runner import CommandRunner +from openbb_core.app.static import coverage as coverage_module from openbb_core.app.static.app_factory import BaseApp from openbb_core.app.static.coverage import Coverage @@ -53,3 +54,34 @@ def test_coverage_reference(coverage): """``Coverage.reference`` returns the loaded reference dict.""" reference = coverage.reference assert isinstance(reference, dict) + + +def test_coverage_repr(coverage): + out = repr(coverage) + assert "/coverage" in out + + +def test_coverage_command_model(coverage): + out = coverage.command_model + assert isinstance(out, dict) + assert out + + +def test_coverage_command_schemas_delegates(monkeypatch, coverage): + seen = {} + + def _fake_get_route_schema_map(app, commands_model, filter_by_provider): + seen["app"] = app + seen["commands_model"] = commands_model + seen["filter"] = filter_by_provider + return {"ok": True} + + monkeypatch.setattr( + coverage_module, "get_route_schema_map", _fake_get_route_schema_map + ) + + out = coverage.command_schemas(filter_by_provider="fake") + assert out == {"ok": True} + assert seen["app"] is coverage._app + assert seen["commands_model"] == coverage._command_map.commands_model + assert seen["filter"] == "fake" diff --git a/openbb_platform/core/tests/app/static/test_docstring_model.py b/openbb_platform/core/tests/app/static/test_docstring_model.py new file mode 100644 index 00000000000..ab5e4509baa --- /dev/null +++ b/openbb_platform/core/tests/app/static/test_docstring_model.py @@ -0,0 +1,268 @@ +"""Drive `generate_model_docstring` kwarg-param branches. + +Targets format_description's "Choices for", "(provider:", "Multiple comma +separated", json_schema_extra Literal/multiple_items_allowed paths. +""" + +from inspect import Parameter +from types import SimpleNamespace +from typing import Annotated, Literal + +import pytest + +pandas = pytest.importorskip("pandas") +pytestmark = pytest.mark.requires_pandas + +from openbb_core.app.model.field import OpenBBField # noqa: E402 +from openbb_core.app.static.package_builder import DocstringGenerator # noqa: E402 + + +class _Query: + """Stand-in for fastapi.Query — class name contains 'Query'.""" + + def __init__(self, description="", json_schema_extra=None): + self.description = description + self.json_schema_extra = json_schema_extra + + +def _kwarg( + type_, + description="", + json_schema_extra=None, + annotation=None, +): + """Build a fake dataclass-style field that mimics provider extras.""" + default = _Query(description=description, json_schema_extra=json_schema_extra) + return SimpleNamespace(type=type_, default=default, annotation=annotation) + + +def test_kwargs_with_literal_choices_and_provider_in_description(): + g = DocstringGenerator() + kwargs = { + "asset_type": _kwarg( + type_=Literal["stock", "etf"], + description="The asset (provider: prov_a)", + ) + } + out = g.generate_model_docstring( + model_name="X", + summary="A summary.", + explicit_params={}, + kwarg_params=kwargs, + returns={}, + results_type="X", + sections=["description", "parameters", "returns"], + ) + assert "asset_type" in out + assert "Choices for prov_a" in out + + +def test_kwargs_with_json_schema_extra_choices(): + g = DocstringGenerator() + kwargs = { + "exchange": _kwarg( + type_=str, + description="An exchange.", + json_schema_extra={ + "prov_a": {"choices": ["nyse", "nasdaq"]}, + "prov_b": {"choices": ["lse", "tsx"], "multiple_items_allowed": True}, + }, + ) + } + out = g.generate_model_docstring( + model_name="X", + summary="x", + explicit_params={}, + kwarg_params=kwargs, + returns={}, + results_type="X", + sections=["parameters"], + ) + assert "Choices for prov_a" in out + assert "Choices for prov_b" in out + assert "Multiple comma separated items allowed for provider(s): prov_b" in out + + +def test_kwargs_with_multiple_items_only(): + g = DocstringGenerator() + kwargs = { + "symbol": _kwarg( + type_=str, + description="The symbol.", + json_schema_extra={"prov_a": {"multiple_items_allowed": True}}, + ) + } + out = g.generate_model_docstring( + model_name="X", + summary="x", + explicit_params={}, + kwarg_params=kwargs, + returns={}, + results_type="X", + sections=["parameters"], + ) + assert "Multiple comma separated items allowed for provider(s): prov_a" in out + + +def test_kwargs_description_with_choices_for_block(): + """Trigger the 'Choices for ' parsing branch in format_description.""" + g = DocstringGenerator() + description = ( + "An exchange.\n" + "Choices for prov_a: nyse, nasdaq\n" + "Choices for prov_b: lse, tsx\n" + "Multiple comma separated items allowed for provider(s): prov_a, prov_b." + ) + kwargs = {"exchange": _kwarg(type_=str, description=description)} + out = g.generate_model_docstring( + model_name="X", + summary="x", + explicit_params={}, + kwarg_params=kwargs, + returns={}, + results_type="X", + sections=["parameters"], + ) + assert "exchange" in out + + +def test_kwargs_description_with_provider_sections(): + """Trigger the '(provider: name)' formatting branch.""" + g = DocstringGenerator() + description = ( + "Same first sentence. (provider: prov_a) Detail A.;" + "Same first sentence. (provider: prov_b) Detail B." + ) + kwargs = {"region": _kwarg(type_=str, description=description)} + out = g.generate_model_docstring( + model_name="X", + summary="x", + explicit_params={}, + kwarg_params=kwargs, + returns={}, + results_type="X", + sections=["parameters"], + ) + assert "region" in out + + +def test_kwargs_with_long_choice_list_wraps(): + g = DocstringGenerator() + long = [f"opt_{i}" for i in range(40)] + kwargs = { + "x": _kwarg( + type_=str, + description="X.", + json_schema_extra={"prov": {"choices": long}}, + ) + } + out = g.generate_model_docstring( + model_name="X", + summary="x", + explicit_params={}, + kwarg_params=kwargs, + returns={}, + results_type="X", + sections=["parameters"], + ) + assert "Choices for prov" in out + + +def test_kwargs_with_query_default_only(): + g = DocstringGenerator() + default = _Query(description="From query default.", json_schema_extra=None) + param = SimpleNamespace(type=str, default=default, annotation=None) + out = g.generate_model_docstring( + model_name="X", + summary="x", + explicit_params={}, + kwarg_params={"y": param}, + returns={}, + results_type="X", + sections=["parameters"], + ) + assert "From query default." in out + + +def test_explicit_provider_and_chart_params(): + g = DocstringGenerator() + annot = Annotated[str, OpenBBField(description="The provider.")] + provider_param = Parameter( + "provider", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=annot + ) + provider_param._annotation = annot # type: ignore[attr-defined] + + chart_annot = Annotated[bool, OpenBBField(description="Chart it.")] + chart_param = Parameter( + "chart", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=chart_annot + ) + chart_param._annotation = chart_annot # type: ignore[attr-defined] + + other_annot = Annotated[str, OpenBBField(description="Other.")] + other = Parameter( + "symbol", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=other_annot + ) + other._annotation = other_annot # type: ignore[attr-defined] + + out = g.generate_model_docstring( + model_name="X", + summary="X command.", + explicit_params={ + "symbol": other, + "provider": provider_param, + "chart": chart_param, + }, + kwarg_params={}, + returns={}, + results_type="X", + sections=["description", "parameters", "returns"], + ) + assert "provider : str" in out + assert "chart : bool" in out + assert "symbol" in out + + +def test_format_description_provider_interface_lookup(monkeypatch): + """Drive lines 647-694: Union p_type with provider_interface.map populated.""" + from dataclasses import dataclass + from typing import Literal + + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + class _FieldInfoLiteral: + annotation = Literal["x", "y", "z"] + + @dataclass + class _Providers: + provider: Literal["openbb", "prov_a", "prov_b"] = "prov_a" + + class _FakePI: + model_providers = {"MyModel": _Providers} + map = { + "MyModel": { + "openbb": {"QueryParams": {"fields": {"symbol": _FieldInfoLiteral()}}}, + "prov_a": {"QueryParams": {"fields": {"symbol": _FieldInfoLiteral()}}}, + "prov_b": {"QueryParams": {"fields": {}}}, + } + } + + monkeypatch.setattr(DocstringGenerator, "provider_interface", _FakePI()) + + kwargs = { + "symbol": _kwarg( + type_=Literal["x", "y", "z"] | None, + description="Sym field. (provider: prov_a, prov_b)", + ) + } + out = DocstringGenerator().generate_model_docstring( + model_name="MyModel", + summary="S.", + explicit_params={}, + kwarg_params=kwargs, + returns={}, + results_type="X", + sections=["description", "parameters", "returns"], + ) + assert "symbol" in out diff --git a/openbb_platform/core/tests/app/static/test_filters.py b/openbb_platform/core/tests/app/static/test_filters.py index 485adb103c1..24bca68e111 100644 --- a/openbb_platform/core/tests/app/static/test_filters.py +++ b/openbb_platform/core/tests/app/static/test_filters.py @@ -74,7 +74,6 @@ def test_filter_inputs( ) -# --- Choices validation tests --- # These tests cover the fix for a silent data corruption bug: # # EXACT BUG SCENARIO (obb.economy.balance_of_payments): @@ -183,7 +182,6 @@ def test_filter_inputs_choices_no_info_no_error(): assert result["extra_params"]["frequency"] == "monthly" -# --- Negative tests: exact bug reproduction --- # These two tests mirror the discriminating condition that first exposed the bug: # "mont" → rejected (caught by ExtraParams Literal validation — was already working) # "monthly" → accepted (equalled the merged default → silently dropped → wrong data) @@ -229,3 +227,44 @@ def test_filter_inputs_post_fix_monthly_rejected_with_choices(): } with pytest.raises(OpenBBError, match="Invalid value 'monthly' for 'frequency'"): filter_inputs(info=_BOP_INFO_WITH_CHOICES, **kwargs) + + +def test_filter_inputs_info_provider_properties_list_legacy(): + kwargs = { + "provider_choices": {"provider": "oecd"}, + "standard_params": {}, + "extra_params": {"frequency": "annual"}, + } + info = { + "frequency": { + "oecd": ["multiple_items_allowed"], + } + } + + result = filter_inputs(info=info, **kwargs) + assert result["extra_params"]["frequency"] == "annual" + + +def test_filter_inputs_info_provider_properties_non_mapping(): + kwargs = { + "provider_choices": {"provider": "oecd"}, + "standard_params": {}, + "extra_params": {"frequency": "annual"}, + } + info = { + "frequency": { + "oecd": "legacy", + } + } + + result = filter_inputs(info=info, **kwargs) + assert result["extra_params"]["frequency"] == "annual" + + +def test_filter_inputs_no_info_list_in_standard_params_raises(): + kwargs = { + "provider_choices": {"provider": "oecd"}, + "standard_params": {"frequency": ["annual", "quarterly"]}, + } + with pytest.raises(OpenBBError, match="multiple items not allowed"): + filter_inputs(**kwargs) diff --git a/openbb_platform/core/tests/app/static/test_import_definition.py b/openbb_platform/core/tests/app/static/test_import_definition.py new file mode 100644 index 00000000000..81c1a7361f8 --- /dev/null +++ b/openbb_platform/core/tests/app/static/test_import_definition.py @@ -0,0 +1,196 @@ +"""Targeted tests for ImportDefinition uncovered branches.""" + +from types import SimpleNamespace +from typing import Annotated + +from openbb_core.app.static.package_builder import ( + import_definition as import_def_module, +) +from openbb_core.app.static.package_builder.import_definition import ImportDefinition + + +def test_sanitize_type_name_strips_quotes_and_subscripts(): + assert ImportDefinition._sanitize_type_name('"typing.Optional[int]"') == "Optional" + assert ImportDefinition._sanitize_type_name("Foo(x)") == "Foo" + assert ( + ImportDefinition._sanitize_type_name("typing_extensions.Annotated") + == "Annotated" + ) + + +def test_filter_hint_type_list_skips_primitives_and_builtins(): + out = ImportDefinition.filter_hint_type_list([int, str, dict, list]) + assert out == [] + + +def test_filter_hint_type_list_keeps_module_types(): + from datetime import datetime + + out = ImportDefinition.filter_hint_type_list([datetime, int]) + assert datetime in out + + +def test_filter_hint_type_list_skips_depends_annotated(): + from fastapi import Depends + + def _dep(): + return 1 + + annotated = Annotated[int, Depends(_dep)] + out = ImportDefinition.filter_hint_type_list([annotated, str]) + assert out == [] + + +def test_filter_hint_type_list_unhashable_type_skipped(): + """Lines 98-100: unhashable type triggers TypeError on `in primitive_types`.""" + + class _UH(list): # subclass list -> unhashable instances + __module__ = "openbb_core.fake_module" + + out = ImportDefinition.filter_hint_type_list([_UH(), int]) + assert all(not isinstance(x, type) or x is not int for x in out) + + +def test_filter_hint_type_list_deduplicates_unhashable(): + """Lines 115-121: dedup loop, comparison TypeError -> identity check.""" + + class _UH: + __module__ = "openbb_core.fake_module" + + def __eq__(self, other): + if isinstance(other, _UH): + raise TypeError("nope") + return False + + def __hash__(self): + return id(self) + + a = _UH() + out = ImportDefinition.filter_hint_type_list([a, a]) + assert len(out) == 1 + + +def test_get_function_hint_type_list_no_validate_clears_response_model(): + async def _endpoint(x: int): + return x + + route = SimpleNamespace( + endpoint=_endpoint, + openapi_extra={"no_validate": True}, + response_model=int, + ) + + out = ImportDefinition.get_function_hint_type_list(route) + assert isinstance(out, list) + assert route.response_model is None + + +def test_get_path_hint_type_list_includes_deprecated_summary_and_router_dep( + monkeypatch, +): + async def _endpoint(a: int) -> int: + return a + + summary = SimpleNamespace(metadata={"x": 1}) + route = SimpleNamespace( + endpoint=_endpoint, + openapi_extra={}, + deprecated=True, + summary=summary, + ) + dep = SimpleNamespace(dependency=lambda: 1) + + monkeypatch.setattr( + import_def_module.PathHandler, + "build_route_map", + staticmethod(lambda: {"/a": route}), + ) + monkeypatch.setattr( + import_def_module.PathHandler, + "build_path_list", + staticmethod(lambda route_map: list(route_map.keys())), + ) + monkeypatch.setattr( + import_def_module.PathHandler, + "get_child_path_list", + staticmethod(lambda path, path_list: ["/a"]), + ) + monkeypatch.setattr( + import_def_module.PathHandler, + "get_route", + staticmethod(lambda path, route_map: route), + ) + monkeypatch.setattr( + import_def_module.PathHandler, + "get_router_dependencies", + staticmethod(lambda _p: [dep]), + ) + + out = ImportDefinition.get_path_hint_type_list("/root") + assert dep.dependency in out + + +def test_build_generates_module_and_from_imports(monkeypatch): + Foo = type("Foo", (), {"__module__": "mymod"}) + Bar = type("Bar", (), {"__module__": "mymod"}) + Baz = type("Baz", (), {"__module__": "anothermod"}) + OptionalLike = type("typing.Optional[int]", (), {"__module__": "xmod"}) + UnionLike = type("A|B", (), {"__module__": "xmod"}) + BadIdent = type("not-valid!", (), {"__module__": "xmod"}) + TypingAny = type("Any", (), {"__module__": "typing"}) + + class _OriginType: + __module__ = "origmod" + __origin__ = list + + class _TypesSkip: + __module__ = "types" + __name__ = "SimpleNamespace" + + monkeypatch.setattr( + ImportDefinition, + "get_path_hint_type_list", + classmethod( + lambda cls, path: [ + Foo, + Bar, + Baz, + OptionalLike, + UnionLike, + BadIdent, + TypingAny, + _OriginType, + _TypesSkip, + ] + ), + ) + + out = ImportDefinition.build("x.y") + assert "import anothermod" in out + assert "import mymod" in out + assert "from anothermod import Baz" in out + assert "from mymod import (" in out + assert "Bar" in out and "Foo" in out + assert "from xmod import" not in out + assert "from types import" not in out + + +def test_filter_hint_type_list_dedup_equal_branch(): + Dup = type("Dup", (), {"__module__": "dupmod"}) + out = ImportDefinition.filter_hint_type_list([Dup, Dup]) + assert len(out) == 1 + + +def test_build_skips_empty_sanitized_and_empty_typing_imports(monkeypatch): + EmptyName = type("", (), {"__module__": "emptymod"}) + TypingMissing = type("DefinitelyNotInTyping", (), {"__module__": "typing"}) + + monkeypatch.setattr( + ImportDefinition, + "get_path_hint_type_list", + classmethod(lambda cls, path: [EmptyName, TypingMissing]), + ) + + out = ImportDefinition.build("x.y") + assert "from emptymod import" not in out + assert "from typing import DefinitelyNotInTyping" not in out diff --git a/openbb_platform/core/tests/app/static/test_package_builder.py b/openbb_platform/core/tests/app/static/test_package_builder.py index d29a5625fbd..f359d6589cb 100644 --- a/openbb_platform/core/tests/app/static/test_package_builder.py +++ b/openbb_platform/core/tests/app/static/test_package_builder.py @@ -1,5 +1,6 @@ """Test the package_builder.py file.""" +import signal from dataclasses import dataclass from inspect import _empty from pathlib import Path @@ -76,6 +77,83 @@ def test_write(package_builder): package_builder._write(code="", name="test", extension="json") +def test_clean_unlinks_requested_modules(tmp_openbb_dir): + builder = PackageBuilder(tmp_openbb_dir) + package_dir = tmp_openbb_dir / "package" + package_dir.mkdir(parents=True, exist_ok=True) + target = package_dir / "equity.py" + target.write_text("x", encoding="utf-8") + + builder._clean(modules=["equity"]) + + assert not target.exists() + + +def test_build_raises_runtime_error_when_lock_is_held(tmp_openbb_dir): + builder = PackageBuilder(tmp_openbb_dir) + + class _HeldLock: + def __init__(self, _f): + return None + + def acquire(self, blocking=False): + raise BlockingIOError + + def release(self): + return None + + with ( + patch("openbb_core.app.static.package_builder.builder.FileLock", _HeldLock), + pytest.raises(RuntimeError, match="Another build process is running"), + ): + builder.build() + + +def test_build_sigterm_handler_cleans_and_exits(tmp_openbb_dir): + builder = PackageBuilder(tmp_openbb_dir, lint=False) + calls = {"clean": 0} + captured = {} + + class _FreeLock: + def __init__(self, _f): + return None + + def acquire(self, blocking=False): + return None + + def release(self): + return None + + def _clean(modules=None): + calls["clean"] += 1 + + def _signal(_sig, handler): + if callable(handler): + captured["handler"] = handler + + def _save_modules(_modules=None, _ext_map=None): + captured["handler"](signal.SIGTERM, None) + + builder._clean = _clean + builder._get_extension_map = lambda: {} + builder._save_modules = _save_modules + builder._save_reference_file = lambda _ext_map=None: None + builder._save_package = lambda: None + + with ( + patch("openbb_core.app.static.package_builder.builder.FileLock", _FreeLock), + patch( + "openbb_core.app.static.package_builder.builder.signal.getsignal", + lambda _s: None, + ), + patch("openbb_core.app.static.package_builder.builder.signal.signal", _signal), + pytest.raises(SystemExit), + ): + builder.build() + + assert calls["clean"] >= 1 + + @pytest.fixture(scope="module") def module_builder(): """Return module builder.""" @@ -104,6 +182,206 @@ def test_build(class_definition): assert code +def test_class_definition_skips_root_command_routes(monkeypatch): + class _Route: + name = "cmd" + path = "/root/cmd" + methods = {"GET"} + openapi_extra = {"model": "M", "examples": ["e"]} + + @staticmethod + def endpoint(): + return None + + monkeypatch.setattr( + PathHandler, "build_module_class", staticmethod(lambda path: "C") + ) + monkeypatch.setattr( + PathHandler, "build_route_map", staticmethod(lambda: {"root/cmd": _Route()}) + ) + monkeypatch.setattr( + PathHandler, "build_path_list", staticmethod(lambda _rm: ["root/cmd"]) + ) + monkeypatch.setattr( + PathHandler, "get_child_path_list", staticmethod(lambda _p, _pl: ["root/cmd"]) + ) + monkeypatch.setattr( + PathHandler, "get_route", staticmethod(lambda _c, _rm: _Route()) + ) + + called = {"command": 0} + monkeypatch.setattr( + MethodDefinition, + "build_command_method", + staticmethod( + lambda **_k: called.__setitem__("command", called["command"] + 1) or "" + ), + ) + + out = ClassDefinition.build("", None) + assert "Routers:" in out + assert called["command"] == 0 + + +def test_class_definition_adds_subroute_loader_for_non_command_route(monkeypatch): + class _Route: + name = "parent" + path = "/a/parent" + methods = None + openapi_extra = None + endpoint = None + + route_map = {"a/parent": _Route(), "a/parent/child": object()} + + monkeypatch.setattr( + PathHandler, "build_module_class", staticmethod(lambda path: "C") + ) + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: route_map)) + monkeypatch.setattr( + PathHandler, "build_path_list", staticmethod(lambda _rm: list(route_map.keys())) + ) + monkeypatch.setattr( + PathHandler, "get_child_path_list", staticmethod(lambda _p, _pl: ["a/parent"]) + ) + monkeypatch.setattr( + PathHandler, "get_route", staticmethod(lambda _c, _rm: _Route()) + ) + + monkeypatch.setattr( + MethodDefinition, + "build_class_loader_method", + staticmethod( + lambda path: ( + f"\n def load_{path.replace('/', '_')}(self):\n pass\n" + ) + ), + ) + monkeypatch.setattr( + MethodDefinition, + "build_command_method", + staticmethod(lambda **_k: ""), + ) + + out = ClassDefinition.build("a", None) + assert "/parent" in out + assert "load_a_parent" in out + + +def test_class_definition_includes_extensions_for_root(monkeypatch): + monkeypatch.setattr( + PathHandler, "build_module_class", staticmethod(lambda path: "C") + ) + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: {})) + monkeypatch.setattr(PathHandler, "build_path_list", staticmethod(lambda _rm: [])) + monkeypatch.setattr( + PathHandler, "get_child_path_list", staticmethod(lambda _p, _pl: []) + ) + + out = ClassDefinition.build( + "", + { + "openbb_core_extension": ["core_ext"], + "openbb_provider_extension": ["prov_ext"], + }, + ) + assert "Extensions:" in out + assert "core_ext" in out + assert "prov_ext" in out + + +def test_class_definition_builds_command_method_for_non_root_path(monkeypatch): + class _Route: + name = "quote" + path = "/equity/quote" + methods = {"GET"} + openapi_extra = {"model": "Quote", "examples": ["ex1"]} + + @staticmethod + def endpoint(): + return None + + monkeypatch.setattr( + PathHandler, "build_module_class", staticmethod(lambda path: "C") + ) + monkeypatch.setattr( + PathHandler, "build_route_map", staticmethod(lambda: {"equity/quote": _Route()}) + ) + monkeypatch.setattr( + PathHandler, "build_path_list", staticmethod(lambda _rm: ["equity/quote"]) + ) + monkeypatch.setattr( + PathHandler, + "get_child_path_list", + staticmethod(lambda _p, _pl: ["equity/quote"]), + ) + monkeypatch.setattr( + PathHandler, "get_route", staticmethod(lambda _c, _rm: _Route()) + ) + + called = {"args": None} + monkeypatch.setattr( + MethodDefinition, + "build_command_method", + staticmethod( + lambda **kwargs: ( + called.__setitem__("args", kwargs) + or "\n def quote(self):\n pass\n" + ) + ), + ) + + out = ClassDefinition.build("equity", None) + assert "quote" in out + assert called["args"]["model_name"] == "Quote" + assert called["args"]["examples"] == ["ex1"] + + +def test_class_definition_missing_route_with_subroutes_builds_loader(monkeypatch): + route_map = {"fx/rates": object(), "fx/rates/intraday": object()} + + monkeypatch.setattr( + PathHandler, "build_module_class", staticmethod(lambda path: "C") + ) + monkeypatch.setattr(PathHandler, "build_route_map", staticmethod(lambda: route_map)) + monkeypatch.setattr( + PathHandler, "build_path_list", staticmethod(lambda _rm: list(route_map.keys())) + ) + monkeypatch.setattr( + PathHandler, "get_child_path_list", staticmethod(lambda _p, _pl: ["fx/rates"]) + ) + monkeypatch.setattr(PathHandler, "get_route", staticmethod(lambda _c, _rm: None)) + monkeypatch.setattr( + MethodDefinition, + "build_class_loader_method", + staticmethod( + lambda path: ( + f"\n def load_{path.replace('/', '_')}(self):\n pass\n" + ) + ), + ) + + out = ClassDefinition.build("fx", None) + assert "/rates" in out + assert "load_fx_rates" in out + + +def test_module_builder_build(monkeypatch): + monkeypatch.setattr( + ImportDefinition, "build", staticmethod(lambda path: "import x\n") + ) + monkeypatch.setattr( + ClassDefinition, + "build", + staticmethod(lambda path, ext_map=None: "class C:\n pass\n"), + ) + + out = ModuleBuilder.build("equity.quote", {"openbb_core_extension": []}) + assert "Autogenerated OpenBB equity.quote Module" in out + assert "THIS FILE IS AUTO-GENERATED" in out + assert "import x" in out + assert "class C" in out + + @pytest.fixture(scope="module") def method_definition(): """Return method definition.""" @@ -674,7 +952,9 @@ def test_generate_model_docstring(docstring_generator, fake_model_name): pi = docstring_generator.provider_interface kwarg_params = pi.params[model_name]["extra"].__dataclass_fields__ return_schema = pi.return_schema[model_name] - returns = return_schema.model_fields + returns = ( + return_schema if isinstance(return_schema, type) else type(return_schema) + ).model_fields formatted_params = { "param1": Parameter("NoneType", kind=Parameter.POSITIONAL_OR_KEYWORD), diff --git a/openbb_platform/core/tests/app/static/test_package_builder_dependencies.py b/openbb_platform/core/tests/app/static/test_package_builder_dependencies.py index a134fae3e8d..70686aab421 100644 --- a/openbb_platform/core/tests/app/static/test_package_builder_dependencies.py +++ b/openbb_platform/core/tests/app/static/test_package_builder_dependencies.py @@ -43,10 +43,6 @@ from openbb_core.app.static.package_builder import ( PathHandler, ) -# --------------------------------------------------------------------------- -# Test helpers / sample dependencies -# --------------------------------------------------------------------------- - class MockDep: """Plain return type used by the safe sample dependency.""" @@ -74,11 +70,6 @@ def make_param(annotation): return signature(_fn).parameters["x"] -# --------------------------------------------------------------------------- -# 1. filter_hint_type_list — skips Depends instances and Annotated[Depends] -# --------------------------------------------------------------------------- - - @pytest.fixture(scope="module") def import_def() -> ImportDefinition: """Module-scoped ``ImportDefinition`` for the filter tests.""" @@ -127,11 +118,6 @@ def test_filter_hint_type_list_keeps_annotated_without_depends(import_def): assert annotated_without_depends in output -# --------------------------------------------------------------------------- -# 2. get_function_hint_type_list — extracts meta.dependency for the import list -# --------------------------------------------------------------------------- - - def test_get_function_hint_type_list_extracts_param_dependency(import_def): """Param-level ``Depends(get_mock_dep)`` must surface ``get_mock_dep`` for import.""" @@ -152,11 +138,6 @@ def test_get_function_hint_type_list_extracts_param_dependency(import_def): ) -# --------------------------------------------------------------------------- -# 3. Router-level dependencies — discovery + rendering -# --------------------------------------------------------------------------- - - @pytest.fixture def router_with_router_level_dep(monkeypatch, fake_router): """Stamp a router-level ``Depends(get_mock_dep)`` onto the test sub-router. diff --git a/openbb_platform/core/tests/app/static/test_package_builder_generated.py b/openbb_platform/core/tests/app/static/test_package_builder_generated.py index d775b8537ba..2733b3b2293 100644 --- a/openbb_platform/core/tests/app/static/test_package_builder_generated.py +++ b/openbb_platform/core/tests/app/static/test_package_builder_generated.py @@ -44,10 +44,6 @@ from openbb_core.app.router import Router, RouterLoader from openbb_core.app.static.package_builder import PackageBuilder from openbb_core.provider.registry_map import RegistryMap -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - def _reset_provider_interface() -> None: """Drop the ``ProviderInterface`` singleton so the next call rebuilds it.""" @@ -129,8 +125,8 @@ def multi_provider_router( def _build(tmp_path: Path) -> Path: """Run ``PackageBuilder.build()`` with production-equivalent lint.""" - if not shutil.which("ruff") or not shutil.which("black"): - pytest.skip("ruff and black are required to lint the generated package") + if not shutil.which("ruff"): + pytest.skip("ruff is required to lint the generated package") builder = PackageBuilder(directory=tmp_path, lint=True, verbose=False) with patch.object(builder, "_save_reference_file"): builder.build() @@ -149,11 +145,6 @@ def built_multi_provider_dir(tmp_path: Path, multi_provider_router: Router) -> P return _build(tmp_path) -# --------------------------------------------------------------------------- -# 1. Generated files are valid Python -# --------------------------------------------------------------------------- - - def _emitted_py_files(package_dir: Path) -> list[Path]: return sorted((package_dir / "package").glob("*.py")) @@ -205,11 +196,6 @@ def test_init_module_is_emitted(built_package_dir): assert "AUTO-GENERATED" in init.read_text() -# --------------------------------------------------------------------------- -# 2. Generated package imports and exposes the expected API -# --------------------------------------------------------------------------- - - def _import_module_from_path(name: str, path: Path) -> Any: """Import a generated module file as a top-level module.""" spec = importlib.util.spec_from_file_location(name, str(path)) @@ -301,11 +287,6 @@ def test_generated_command_dispatches_to_container_run( ) -# --------------------------------------------------------------------------- -# 3. Behavioral contract: provider routing, dispatch payload, sub-routers -# --------------------------------------------------------------------------- - - def _locate_container_class(package_dir: Path, method_name: str): """Import every generated module and return the container class exposing ``method_name``.""" for path in [p for p in _emitted_py_files(package_dir) if p.name != "__init__.py"]: @@ -503,8 +484,8 @@ def test_command_docstring_preserved_in_generated_source(built_package_dir): def test_build_is_deterministic(tmp_path: Path, fake_router: Router): """Running ``PackageBuilder.build()`` twice over the same input must produce byte-identical output.""" - if not shutil.which("ruff") or not shutil.which("black"): - pytest.skip("ruff and black are required to lint the generated package") + if not shutil.which("ruff"): + pytest.skip("ruff is required to lint the generated package") out_a = tmp_path / "a" out_b = tmp_path / "b" @@ -531,11 +512,6 @@ def test_build_is_deterministic(tmp_path: Path, fake_router: Router): ) -# --------------------------------------------------------------------------- -# 4. Special command shapes: multiple commands per router, deprecation, raw -# --------------------------------------------------------------------------- - - def test_router_with_two_commands_emits_both_methods( isolated_provider_interface, fake_model_name, install_router, tmp_path: Path ): @@ -621,10 +597,6 @@ def test_command_without_model_emits_user_signature( ) -# --------------------------------------------------------------------------- -# 6. End-to-end: dependency injection wires through the generated module -# --------------------------------------------------------------------------- -# # These tests build the *real* package against a router that carries both # router-level and param-level FastAPI ``Depends(...)``, then read the # emitted ``.py`` file off disk and assert the DI wiring shows up in the @@ -690,7 +662,10 @@ def test_generated_module_renders_router_level_dependency_wiring( "router-level Depends did not render the dep instantiation line in the " f"emitted module:\n--- source ---\n{source}\n--- end ---" ) - assert 'kwargs["di_mock_dep"] = di_mock_dep' in source, ( + assert ( + 'kwargs["di_mock_dep"] = di_mock_dep' in source + or "kwargs['di_mock_dep'] = di_mock_dep" in source + ), ( "router-level Depends did not render the kwargs hand-off line in the " f"emitted module:\n--- source ---\n{source}\n--- end ---" ) diff --git a/openbb_platform/core/tests/app/static/test_reference_generator_e2e.py b/openbb_platform/core/tests/app/static/test_reference_generator_e2e.py new file mode 100644 index 00000000000..5fc16a58871 --- /dev/null +++ b/openbb_platform/core/tests/app/static/test_reference_generator_e2e.py @@ -0,0 +1,189 @@ +"""Drive the reference_generator + full builder paths via fake_router. + +The existing `built_package_dir` fixture patches out `_save_reference_file` +which means `ReferenceGenerator.get_paths`/`get_routers` (~660 lines) never +runs. This file repeats the build *without* that patch so reference_generator +gets exercised. +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from openbb_core.app.router import Router, RouterLoader +from openbb_core.app.static.package_builder import PackageBuilder +from openbb_core.app.static.package_builder.reference_generator import ( + ReferenceGenerator, +) +from openbb_core.provider.abstract.data import Data + +pytestmark = pytest.mark.requires_pandas + + +class _TransformResult(Data): + """Result row.""" + + symbol: str = "" + value: float = 0.0 + + +@pytest.fixture +def install_router(monkeypatch, isolated_provider_interface): + """Override conftest version to support both class- and instance-style calls.""" + + monkeypatch.setattr( + "openbb_core.app.static.package_builder.docstring_generator." + "DocstringGenerator.provider_interface", + isolated_provider_interface, + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.reference_generator." + "ReferenceGenerator.pi", + isolated_provider_interface, + ) + + def _install(child: Router, prefix: str = "/test") -> Router: + parent = Router() + parent.include_router(router=child, prefix=prefix) + RouterLoader.from_extensions.cache_clear() # type: ignore[attr-defined] + monkeypatch.setattr( + "openbb_core.app.router.RouterLoader.from_extensions", + staticmethod(lambda: parent), + ) + return parent + + return _install + + +@pytest.fixture +def built_package_with_reference( + tmp_path: Path, fake_router: Router +) -> tuple[Path, dict]: + """Run the FULL `PackageBuilder.build()` including `_save_reference_file`.""" + builder = PackageBuilder(directory=tmp_path, lint=False, verbose=False) + captured: dict = {} + + real_save = builder._save_reference_file + + def spy(ext_map=None): + captured["ext_map"] = ext_map + return real_save(ext_map) + + with patch.object(builder, "_save_reference_file", side_effect=spy): + builder.build() + + ref_path = tmp_path / "assets" / "reference.json" + assert ref_path.exists(), f"reference.json not written to {ref_path}" + + import json + + captured["reference"] = json.loads(ref_path.read_text()) + return tmp_path, captured + + +def test_reference_file_written_with_paths_and_routers(built_package_with_reference): + _, captured = built_package_with_reference + ref = captured["reference"] + assert "paths" in ref + assert "routers" in ref + assert "openbb" in ref + + +def test_reference_file_paths_exposes_fake_route(built_package_with_reference): + _, captured = built_package_with_reference + paths = captured["reference"]["paths"] + assert paths, "Expected at least one path in reference.json" + assert any("/test/" in p for p in paths), f"missing /test/* path; got {list(paths)}" + + +def test_reference_file_routers_keyed_by_path(built_package_with_reference): + _, captured = built_package_with_reference + routers = captured["reference"]["routers"] + assert isinstance(routers, dict) + + +def test_reference_get_paths_directly(fake_router): + """Drive ReferenceGenerator.get_paths against the real route_map.""" + from openbb_core.app.static.package_builder.path_handler import PathHandler + + route_map = PathHandler.build_route_map() + paths = ReferenceGenerator.get_paths(route_map) + assert isinstance(paths, dict) + assert paths + # Each path entry should expose a 'deprecated' boolean and 'description' string + first = next(iter(paths.values())) + assert isinstance(first, dict) + + +def test_reference_get_routers_directly(fake_router): + from openbb_core.app.static.package_builder.path_handler import PathHandler + + route_map = PathHandler.build_route_map() + routers = ReferenceGenerator.get_routers(route_map) + assert isinstance(routers, dict) + + +@pytest.fixture +def router_with_data_processing( + isolated_provider_interface, fake_model_name, install_router +): + """Add a non-model (POST data-processing) command alongside a model command.""" + from typing import Annotated + + from openbb_core.app.model.command_context import CommandContext + from openbb_core.app.model.field import OpenBBField + from openbb_core.app.model.obbject import OBBject + from openbb_core.app.provider_interface import ( + ExtraParams, + ProviderChoices, + StandardParams, + ) + + child = Router() + + async def model_cmd( + cc: CommandContext, + provider_choices: ProviderChoices, + standard_params: StandardParams, + extra_params: ExtraParams, + ) -> OBBject: # type: ignore[empty-body] + """Model command.""" + + child.command(model=fake_model_name)(model_cmd) + + @child.command(methods=["POST"]) + async def transform( + symbol: Annotated[str, OpenBBField(description="The symbol.")], + days: int = 7, + ) -> OBBject[list[_TransformResult]]: + """Process some data. + + Parameters + ---------- + symbol : str + The symbol. + days : int + The days. + + Returns + ------- + OBBject[list[_TransformResult]] + Processed data. + """ + + return install_router(child, prefix="/test") + + +def test_reference_data_processing_endpoint(tmp_path, router_with_data_processing): + builder = PackageBuilder(directory=tmp_path, lint=False, verbose=False) + builder.build() + import json + + ref = json.loads((tmp_path / "assets" / "reference.json").read_text()) + paths = ref["paths"] + assert any("/test/transform" in p for p in paths) + transform = next(p for k, p in paths.items() if "transform" in k) + assert "parameters" in transform + # data-processing path also produced parameters from formatted_params + assert transform["parameters"].get("standard") is not None diff --git a/openbb_platform/core/tests/app/static/test_reference_generator_unit.py b/openbb_platform/core/tests/app/static/test_reference_generator_unit.py new file mode 100644 index 00000000000..0095ce98a13 --- /dev/null +++ b/openbb_platform/core/tests/app/static/test_reference_generator_unit.py @@ -0,0 +1,1122 @@ +"""Direct unit tests for ReferenceGenerator static helpers.""" + +from typing import Annotated + +import pytest +from pydantic_core import PydanticUndefined + +pandas = pytest.importorskip("pandas") +pytestmark = pytest.mark.requires_pandas + +from openbb_core.app.model.field import OpenBBField # noqa: E402 +from openbb_core.app.model.obbject import OBBject # noqa: E402 +from openbb_core.app.static.package_builder.reference_generator import ( # noqa: E402 + ReferenceGenerator, +) +from openbb_core.provider.abstract.data import Data # noqa: E402 + + +class _ResData(Data): + """Simple typed result model for return-type extraction tests.""" + + symbol: str = "AAPL" + + +def _f_no_annotation(): + return None + + +def _f_obbject_typed() -> OBBject[list[_ResData]]: + return OBBject(results=[]) + + +def test_clean_string_values_str_basic(): + out = ReferenceGenerator._clean_string_values("hello") + assert out == "hello" + + +def test_clean_string_values_replaces_data_path(): + out = ReferenceGenerator._clean_string_values( + "list[openbb_core.provider.abstract.data.Data]" + ) + assert out == "list[Data]" + + +def test_clean_string_values_data_path_singular(): + out = ReferenceGenerator._clean_string_values( + "openbb_core.provider.abstract.data.Data" + ) + assert out == "Data" + + +def test_clean_string_values_union_collapses(): + out = ReferenceGenerator._clean_string_values("Union[int, str]") + assert "|" in out + + +def test_clean_string_values_unquoted_literal(): + out = ReferenceGenerator._clean_string_values("Literal[a, b, c]") + assert "'a'" in out and "'b'" in out and "'c'" in out + + +def test_clean_string_values_dict_recurses(): + out = ReferenceGenerator._clean_string_values({"k": 'val "x"'}) + assert out == {"k": "val 'x'"} + + +def test_clean_string_values_list_recurses(): + out = ReferenceGenerator._clean_string_values(['a "x"', "b"]) + assert out == ["a 'x'", "b"] + + +def test_clean_string_values_passthrough_non_string(): + assert ReferenceGenerator._clean_string_values(42) == 42 + assert ReferenceGenerator._clean_string_values(None) is None + + +def test_clean_string_values_union_exception_path(monkeypatch): + import builtins + + original_set = builtins.set + monkeypatch.setattr( + builtins, + "set", + lambda *_a, **_k: (_ for _ in ()).throw(Exception("x")), + ) + out = ReferenceGenerator._clean_string_values("Union[int, str]") + monkeypatch.setattr(builtins, "set", original_set) + assert out == "Union[int, str]" + + +def test_get_post_method_returns_info_extracts_obbject(): + docstring = ( + "Summary.\n\nReturns\n-------\nOBBject[list[MyResult]]\n The results.\n" + ) + out = ReferenceGenerator._get_post_method_returns_info(docstring) + assert out["name"] == "results" + assert "MyResult" in out["type"] + assert out["description"] + + +def test_get_post_method_returns_info_no_match(): + out = ReferenceGenerator._get_post_method_returns_info("no returns block here") + assert out == {} + + +def test_get_post_method_returns_info_list_type(): + docstring = "x.\n\nReturns\n-------\nlist[MyData]\n A list of results.\n" + out = ReferenceGenerator._get_post_method_returns_info(docstring) + assert out["type"] == "MyData" + + +def test_extract_parameters_from_docstring_basic(): + doc = ( + "Summary.\n\n" + "Parameters\n" + "----------\n" + " symbol : str\n" + " The symbol.\n" + " days : int = 7\n" + " Number of days.\n\n" + "Returns\n" + "-------\n" + " Any\n" + ) + out = ReferenceGenerator._get_post_method_parameters_info(doc) + names = [p["name"] for p in out] + assert "symbol" in names + assert "days" in names + + +def test_extract_parameters_from_docstring_no_section(): + out = ReferenceGenerator._get_post_method_parameters_info("no params here") + assert out == [] + + +def test_extract_parameters_from_docstring_parameters_without_returns(): + doc = "Summary.\n\nParameters\n----------\n symbol : str\n The symbol.\n" + out = ReferenceGenerator._get_post_method_parameters_info(doc) + assert out and out[0]["name"] == "symbol" + + +def test_extract_parameters_from_docstring_optional_marker(): + doc = ( + "Summary.\n\n" + "Parameters\n" + "----------\n" + " region : str, optional\n" + " The region.\n\n" + "Returns\n-------\nAny\n" + ) + out = ReferenceGenerator._get_post_method_parameters_info(doc) + assert out + assert out[0]["optional"] is True + assert "optional" not in out[0]["type"] + + +def _func_with_signature( + symbol: Annotated[str, OpenBBField(description="The sym.")], + limit: int | None = None, +): + """Body.""" + + +def test_get_function_signature_info_basic(): + out = ReferenceGenerator._get_function_signature_info(_func_with_signature) + names = [p["name"] for p in out] + assert "symbol" in names + assert "limit" in names + sym = next(p for p in out if p["name"] == "symbol") + assert "The sym." in sym["description"] + + +def test_get_function_signature_info_skips_self_and_cc(): + def f(self, cc, x: int = 1): + pass + + out = ReferenceGenerator._get_function_signature_info(f) + names = [p["name"] for p in out] + assert "self" not in names + assert "cc" not in names + assert "x" in names + + +def test_get_function_signature_info_skips_depends_default(): + from fastapi import Depends + + def dep(): + return 1 + + def f(x: int = Depends(dep)): + pass + + out = ReferenceGenerator._get_function_signature_info(f) + assert all(p["name"] != "x" for p in out) + + +def test_get_function_signature_info_query_default_branch(): + from fastapi import Query + + def f(symbol: str = Query(default="AAPL", description="Sym")): + return None + + out = ReferenceGenerator._get_function_signature_info(f) + assert out + assert out[0]["name"] == "symbol" + assert out[0]["default"] == "AAPL" + assert out[0]["optional"] is True + + +def test_get_function_signature_info_skips_dependency_annotation_metadata(): + class _Dep: + dependency = object() + + def f(x: Annotated[str, _Dep()]): + return None + + out = ReferenceGenerator._get_function_signature_info(f) + assert out == [] + + +def _f_obbject_data() -> OBBject[_ResData]: + """Func.""" + + +def _f_obbject_dict() -> OBBject[dict[str, _ResData]]: + """Func.""" + + +def _f_obbject_plain() -> OBBject: + """Plain OBBject.""" + + +def _f_int() -> int: + pass + + +def _f_str_dotted() -> "openbb_core.provider.abstract.data.Data": # noqa + pass + + +def test_extract_return_type_no_annotation(): + out = ReferenceGenerator._extract_return_type(_f_no_annotation) + assert out == {"type": "Any"} + + +def test_extract_return_type_obbject_list(): + out = ReferenceGenerator._extract_return_type(_f_obbject_typed) + assert isinstance(out, dict) + assert "OBBject" in out + + +def test_extract_return_type_obbject_dict(): + out = ReferenceGenerator._extract_return_type(_f_obbject_dict) + assert isinstance(out, dict) + + +def test_extract_return_type_obbject_data(): + out = ReferenceGenerator._extract_return_type(_f_obbject_data) + assert isinstance(out, dict) + + +def test_extract_return_type_obbject_plain(): + out = ReferenceGenerator._extract_return_type(_f_obbject_plain) + assert isinstance(out, dict) + + +def test_extract_return_type_int_basic(): + out = ReferenceGenerator._extract_return_type(_f_int) + assert out == "int" + + +def test_get_obbject_returns_fields_with_provider(): + out = ReferenceGenerator._get_obbject_returns_fields("MyModel", "Literal['a','b']") + assert any(f["name"] == "results" and f["type"] == "MyModel" for f in out) + assert any(f["name"] == "provider" and f["type"] == "Literal['a','b']" for f in out) + + +def test_get_obbject_returns_fields_no_provider_uses_str(): + out = ReferenceGenerator._get_obbject_returns_fields("MyModel", "") + assert any(f["name"] == "provider" and f["type"] == "str" for f in out) + + +def test_apply_query_param_extras_multiple_items_provider_dict(): + desc, ftype, choices = ReferenceGenerator._apply_query_param_extras( + "Symbol", + "str", + None, + {"prov_a": {"multiple_items_allowed": True, "choices": ["x"]}}, + ) + assert "Multiple items allowed" in desc + assert "prov_a" in desc + assert "list[str]" in ftype + assert choices == ["x"] + + +def test_apply_query_param_extras_multiple_items_provider_list(): + desc, ftype, choices = ReferenceGenerator._apply_query_param_extras( + "Symbol", + "str", + None, + {"prov_a": ["multiple_items_allowed"]}, + ) + assert "Multiple items allowed" in desc + assert "prov_a" in desc + + +def test_apply_query_param_extras_choices_only_no_multi(): + desc, ftype, choices = ReferenceGenerator._apply_query_param_extras( + "Sym", + "str", + None, + {"prov_a": {"choices": ["x", "y"]}}, + ) + assert choices == ["x", "y"] + assert "Multiple items" not in desc + assert ftype == "str" + + +def test_apply_query_param_extras_top_level_multi(): + desc, ftype, choices = ReferenceGenerator._apply_query_param_extras( + "Sym", + "str", + None, + {"multiple_items_allowed": True}, + ) + assert "Multiple items allowed" in desc + assert "list[str]" in ftype + + +def test_apply_query_param_extras_no_extras_pass_through(): + desc, ftype, choices = ReferenceGenerator._apply_query_param_extras( + "Plain", "str", ["a"], {} + ) + assert desc == "Plain" + assert ftype == "str" + assert choices == ["a"] + + +def _f_returns_data() -> _ResData: + pass + + +def _f_returns_dict_str() -> "dict[str, int]": + pass + + +def _f_returns_list_data() -> list[_ResData]: + pass + + +def test_extract_return_type_data_class_returns_name(): + out = ReferenceGenerator._extract_return_type(_f_returns_data) + assert out == "_ResData" + + +def test_extract_return_type_container_type(): + out = ReferenceGenerator._extract_return_type(_f_returns_list_data) + assert "list" in out and "_ResData" in out + + +def _f_obbject_unbound_with_doc() -> OBBject[list[Data]]: + """Func with doc. + + Returns + ------- + OBBject[MyDocModel] + Some docs. + """ + + +def test_extract_return_type_obbject_fallback_from_docstring(): + import inspect + + from openbb_core.app.static.package_builder import reference_generator as rg + + return_annotation = inspect.signature(_f_obbject_unbound_with_doc).return_annotation + + original_get_origin = rg.get_origin + original_get_args = rg.get_args + + def _fake_get_origin(tp): + if tp is return_annotation: + return OBBject + if str(tp) == "list[Data]": + return list + return original_get_origin(tp) + + def _fake_get_args(tp): + if tp is return_annotation: + return (list[Data],) + if str(tp) == "list[Data]": + return (Data,) + return original_get_args(tp) + + rg.get_origin = _fake_get_origin + rg.get_args = _fake_get_args + out = ReferenceGenerator._extract_return_type(_f_obbject_unbound_with_doc) + rg.get_origin = original_get_origin + rg.get_args = original_get_args + assert isinstance(out, dict) + assert "OBBject" in out + assert out["OBBject"][0]["type"] == "MyDocModel" + + +def test_get_provider_field_params_uses_class_and_field_json_extra(monkeypatch): + from types import SimpleNamespace + + class _ProviderClass: + __json_schema_extra__ = { + "symbol": { + "openbb": {"choices": ["A", "B"]}, + "multiple_items_allowed": True, + } + } + + field_info = SimpleNamespace( + annotation=str, + is_required=lambda: True, + description='A "desc"', + default="", + json_schema_extra={"openbb": {"choices": ["C"]}}, + ) + + fake_pi = SimpleNamespace( + map={ + "M": { + "openbb": { + "QueryParams": { + "class": _ProviderClass, + "fields": {"symbol": field_info}, + } + } + } + } + ) + + monkeypatch.setattr(ReferenceGenerator, "pi", fake_pi) + out = ReferenceGenerator._get_provider_field_params("M", "QueryParams", "openbb") + assert out + assert out[0]["name"] == "symbol" + assert out[0]["choices"] == ["C"] + assert out[0]["default"] is None + + +def test_resolve_field_type_str_annotated_regex_path(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + monkeypatch.setattr( + DocstringGenerator, + "get_field_type", + staticmethod(lambda *_args, **_kwargs: "Annotated | None"), + ) + + field_info = SimpleNamespace( + annotation="typing.Annotated[CustomThing, OpenBBField()]", + is_required=lambda: False, + ) + + field_type_str, is_required = ReferenceGenerator._resolve_field_type_str(field_info) + assert field_type_str == "CustomThing | None" + assert is_required is False + + +def test_resolve_field_type_str_optional_suffix_cleanup(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder.docstring_generator import ( + DocstringGenerator, + ) + + monkeypatch.setattr( + DocstringGenerator, + "get_field_type", + staticmethod(lambda *_args, **_kwargs: "int, optional"), + ) + + field_info = SimpleNamespace( + annotation=int, + is_required=lambda: True, + ) + + field_type_str, is_required = ReferenceGenerator._resolve_field_type_str(field_info) + assert field_type_str == "int" + assert is_required is False + + +def test_extract_return_type_obbject_two_args_path(monkeypatch): + import inspect + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import reference_generator as rg + + def _func() -> OBBject: + return OBBject(results=[]) + + ann = inspect.signature(_func).return_annotation + + monkeypatch.setattr(rg, "get_origin", lambda tp: OBBject if tp is ann else None) + monkeypatch.setattr( + rg, + "get_args", + lambda tp: (int, SimpleNamespace(__name__="TwoArgModel")) if tp is ann else (), + ) + + out = ReferenceGenerator._extract_return_type(_func) + assert out["OBBject"][0]["type"] == "TwoArgModel" + + +def test_extract_return_type_obbject_unclosed_bracket_completion(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import reference_generator as rg + + def _func(): + return None + + monkeypatch.setattr( + rg.inspect, + "signature", + lambda _f: SimpleNamespace(return_annotation="OBBject[X][Model[]]"), + ) + monkeypatch.setattr(rg, "get_type_hints", lambda _f: {}) + monkeypatch.setattr(rg, "get_origin", lambda _t: None) + + out = ReferenceGenerator._extract_return_type(_func) + assert out["OBBject"][0]["type"] == "Model[]" + + +def test_get_routers_uses_main_router(monkeypatch, fake_router): + from openbb_core.app.router import RouterLoader + from openbb_core.app.static.package_builder.path_handler import PathHandler + + # Existing fixture lambda has wrong arity for instance call; rebind + parent = RouterLoader.from_extensions() + monkeypatch.setattr( + "openbb_core.app.router.RouterLoader.from_extensions", + staticmethod(lambda: parent), + ) + route_map = PathHandler.build_route_map() + out = ReferenceGenerator.get_routers(route_map) + assert isinstance(out, dict) + + +def test_get_paths_removes_standard_choices_when_provider_specific_exists(monkeypatch): + from types import SimpleNamespace + + def endpoint(): + return None + + route = SimpleNamespace( + methods={"GET"}, + endpoint=endpoint, + description="desc", + openapi_extra={"model": "M"}, + ) + + fake_pi = SimpleNamespace(map={"M": {"openbb": {}, "prov": {}}}) + monkeypatch.setattr(ReferenceGenerator, "pi", fake_pi) + monkeypatch.setattr( + ReferenceGenerator, + "_get_provider_field_params", + classmethod( + lambda cls, _m, params_type, provider="openbb": ( + [{"name": "symbol", "choices": ["A"]}] + if params_type == "QueryParams" and provider == "openbb" + else ( + [{"name": "symbol", "choices": ["B"]}] + if params_type == "QueryParams" + else [{"name": "x"}] + ) + ) + ), + ) + monkeypatch.setattr( + ReferenceGenerator, + "_get_provider_parameter_info", + classmethod(lambda cls, _m: {"type": "Literal['openbb','prov']"}), + ) + monkeypatch.setattr( + ReferenceGenerator, + "_extract_return_type", + staticmethod( + lambda _f: { + "OBBject": [{"name": "results", "type": "Any", "description": "r"}] + } + ), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.is_deprecated_function", + staticmethod(lambda _p: False), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.get_deprecation_message", + staticmethod(lambda _p: ""), + ) + monkeypatch.setattr( + ReferenceGenerator, + "_get_endpoint_examples", + classmethod(lambda cls, _p, _f, _e: []), + ) + + out = ReferenceGenerator.get_paths({"/x": route}) + assert out["/x"]["parameters"]["standard"][0]["choices"] is None + + +def test_get_paths_no_validate_adds_returns_any(monkeypatch): + from types import SimpleNamespace + + def endpoint(): + return None + + route = SimpleNamespace( + methods={"GET"}, + endpoint=endpoint, + description="desc", + openapi_extra={"model": "M", "no_validate": True}, + ) + fake_pi = SimpleNamespace(map={"M": {"openbb": {}}}) + monkeypatch.setattr(ReferenceGenerator, "pi", fake_pi) + monkeypatch.setattr( + ReferenceGenerator, + "_get_provider_field_params", + classmethod(lambda cls, _m, _pt, provider="openbb": [{"name": "x"}]), + ) + monkeypatch.setattr( + ReferenceGenerator, + "_get_provider_parameter_info", + classmethod(lambda cls, _m: {"type": "str"}), + ) + monkeypatch.setattr( + ReferenceGenerator, + "_extract_return_type", + staticmethod(lambda _f: {"OBBject": [{"name": "results", "type": "Any"}]}), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.is_deprecated_function", + staticmethod(lambda _p: False), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.get_deprecation_message", + staticmethod(lambda _p: ""), + ) + monkeypatch.setattr( + ReferenceGenerator, + "_get_endpoint_examples", + classmethod(lambda cls, _p, _f, _e: []), + ) + + out = ReferenceGenerator.get_paths({"/x": route}) + assert out["/x"]["returns"]["Any"]["description"] == "Unvalidated results object." + + +def test_get_routers_adds_description_entries(monkeypatch): + from types import SimpleNamespace + + class _Main: + @staticmethod + def get_attr(path, attr): + if path == "/a" and attr == "description": + return "desc a" + return None + + monkeypatch.setattr( + "openbb_core.app.static.package_builder.reference_generator.RouterLoader", + lambda: SimpleNamespace(from_extensions=_Main), + ) + + out = ReferenceGenerator.get_routers({"/a/b": object()}) + assert out["/a"]["description"] == "desc a" + + +def test_get_paths_data_processing_parameter_and_data_model_extraction(monkeypatch): + from inspect import Parameter + from types import SimpleNamespace + from typing import Annotated + + from pydantic import BaseModel, Field + + class _Ret(BaseModel): + name: str = Field(default="x", description="Name") + + def endpoint(symbol: str, amount: int = 1): + return None + + route = SimpleNamespace( + methods=None, + endpoint=endpoint, + openapi_extra={}, + description="d", + ) + + fake_pi = SimpleNamespace(map={}) + monkeypatch.setattr(ReferenceGenerator, "pi", fake_pi) + monkeypatch.setattr( + ReferenceGenerator, + "_extract_return_type", + staticmethod( + lambda _f: { + "OBBject": [{"name": "results", "type": "_Ret", "description": "r"}] + } + ), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.is_deprecated_function", + staticmethod(lambda _p: False), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.get_deprecation_message", + staticmethod(lambda _p: ""), + ) + monkeypatch.setattr( + ReferenceGenerator, + "_get_endpoint_examples", + classmethod(lambda cls, _p, _f, _e: []), + ) + + formatted_params = { + "symbol": Parameter( + name="symbol", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, OpenBBField(description="sym")], + default=Parameter.empty, + ), + "amount": Parameter( + name="amount", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=int, + default=1, + ), + } + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.format_params", + staticmethod(lambda **_k: formatted_params), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.docstring_generator.DocstringGenerator.generate", + classmethod(lambda cls, **_k: "Desc\n\nParameters\n----------\n"), + ) + + import sys + + setattr(sys.modules[endpoint.__module__], "_Ret", _Ret) + + out = ReferenceGenerator.get_paths({"/dp": route}) + assert out["/dp"]["description"] == "Desc" + assert out["/dp"]["parameters"]["standard"][0]["name"] == "symbol" + assert out["/dp"]["parameters"]["standard"][1]["type"] == "int" + assert out["/dp"]["returns"]["OBBject"] + assert out["/dp"]["data"]["standard"][0]["name"] == "name" + + +def test_get_provider_field_params_provider_specific_extra_and_expanded_type( + monkeypatch, +): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import method_definition as md + + field_info = SimpleNamespace( + annotation=str, + is_required=lambda: False, + description="desc", + default="", + json_schema_extra={"prov": {"choices": ["x"]}}, + ) + + fake_pi = SimpleNamespace( + map={ + "M": { + "prov": { + "QueryParams": { + "class": object, + "fields": {"symbol": field_info}, + } + } + } + } + ) + monkeypatch.setattr(ReferenceGenerator, "pi", fake_pi) + monkeypatch.setattr(md.MethodDefinition, "TYPE_EXPANSION", {"symbol": list[str]}) + + out = ReferenceGenerator._get_provider_field_params("M", "QueryParams", "prov") + assert out[0]["choices"] == ["x"] + assert out[0]["default"] is None + + +def test_get_provider_field_params_field_extra_choices_and_expanded_field(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import method_definition as md + + field_info = SimpleNamespace( + annotation=str, + is_required=lambda: False, + description="desc", + default="", + json_schema_extra={"choices": ["A", "B"]}, + ) + fake_pi = SimpleNamespace( + map={ + "M": { + "openbb": { + "QueryParams": {"class": object, "fields": {"symbol": field_info}}, + } + } + } + ) + monkeypatch.setattr(ReferenceGenerator, "pi", fake_pi) + monkeypatch.setattr(md.MethodDefinition, "TYPE_EXPANSION", {"symbol": list[str]}) + out = ReferenceGenerator._get_provider_field_params("M", "QueryParams", "openbb") + assert out[0]["choices"] == ["A", "B"] + assert out[0]["type"] + + +def test_extract_return_type_inner_type_name_and_bound_branches(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import reference_generator as rg + + def _f(): + return None + + ann = OBBject + + class _Inner: + __name__ = "Inner" + __bound__ = SimpleNamespace(__name__="Bounded") + + monkeypatch.setattr( + rg.inspect, "signature", lambda _fn: SimpleNamespace(return_annotation=ann) + ) + monkeypatch.setattr(rg, "get_type_hints", lambda _fn: {"return": ann}) + monkeypatch.setattr(rg, "get_origin", lambda t: OBBject if t is ann else None) + monkeypatch.setattr(rg, "get_args", lambda t: (_Inner,) if t is ann else ()) + + out = ReferenceGenerator._extract_return_type(_f) + assert out["OBBject"][0]["type"] == "Bounded" + + +def test_extract_return_type_inner_type_name_attr_branch(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import reference_generator as rg + + def _f(): + return None + + ann = OBBject + inner = SimpleNamespace(_name="InnerAlias") + + monkeypatch.setattr( + rg.inspect, "signature", lambda _fn: SimpleNamespace(return_annotation=ann) + ) + monkeypatch.setattr(rg, "get_type_hints", lambda _fn: {"return": ann}) + monkeypatch.setattr(rg, "get_origin", lambda t: OBBject if t is ann else None) + monkeypatch.setattr(rg, "get_args", lambda t: (inner,) if t is ann else ()) + + out = ReferenceGenerator._extract_return_type(_f) + assert out["OBBject"][0]["type"] == "InnerAlias" + + +def test_get_provider_field_params_expanded_type_non_queryparams(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import method_definition as md + + field_info = SimpleNamespace( + annotation=str, + is_required=lambda: False, + description="desc", + default="", + json_schema_extra={}, + ) + fake_pi = SimpleNamespace( + map={ + "M": { + "openbb": { + "Data": {"class": object, "fields": {"symbol": field_info}}, + } + } + } + ) + monkeypatch.setattr(ReferenceGenerator, "pi", fake_pi) + monkeypatch.setattr(md.MethodDefinition, "TYPE_EXPANSION", {"symbol": list[str]}) + out = ReferenceGenerator._get_provider_field_params("M", "Data", "openbb") + assert "list[str]" in out[0]["type"] + + +def test_resolve_field_type_str_annotated_empty_args_break_and_fallback(monkeypatch): + from types import SimpleNamespace + from typing import Annotated + + from openbb_core.app.static.package_builder import reference_generator as rg + + fake_field = SimpleNamespace(annotation="X", is_required=lambda: True) + + monkeypatch.setattr(rg, "get_origin", lambda t: Annotated if t == "X" else None) + monkeypatch.setattr(rg, "get_args", lambda _t: ()) + monkeypatch.setattr( + rg.DocstringGenerator, + "get_field_type", + staticmethod(lambda *_a, **_k: "AnnotatedType"), + ) + out_type, out_required = ReferenceGenerator._resolve_field_type_str(fake_field) + assert out_type == "X" + assert out_required is True + + +def test_resolve_field_type_str_annotated_type_repr_else_branch(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.static.package_builder import reference_generator as rg + + class _NoName: + def __str__(self): + return "NoNameType" + + fake_field = SimpleNamespace(annotation=_NoName(), is_required=lambda: True) + + monkeypatch.setattr(rg, "get_origin", lambda _t: None) + monkeypatch.setattr( + rg.DocstringGenerator, + "get_field_type", + staticmethod(lambda *_a, **_k: "AnnotatedPlaceholder"), + ) + out_type, _ = ReferenceGenerator._resolve_field_type_str(fake_field) + assert out_type == "NoNameType" + + +def test_resolve_field_type_str_annotated_unwrap_and_name_branch(monkeypatch): + from types import SimpleNamespace + from typing import Annotated + + from openbb_core.app.static.package_builder import reference_generator as rg + + class _Named: + __name__ = "NamedType" + + fake_field = SimpleNamespace(annotation="wrapped", is_required=lambda: True) + + monkeypatch.setattr( + rg, "get_origin", lambda t: Annotated if t == "wrapped" else None + ) + monkeypatch.setattr(rg, "get_args", lambda _t: (_Named,)) + monkeypatch.setattr( + rg.DocstringGenerator, + "get_field_type", + staticmethod(lambda *_a, **_k: "AnnotatedAlias"), + ) + out_type, out_required = ReferenceGenerator._resolve_field_type_str(fake_field) + assert out_type == "_Named" + assert out_required is True + + +def test_get_paths_data_processing_empty_docstring_continue(monkeypatch): + from starlette.routing import Route + + async def endpoint(): + return None + + endpoint.operation_id = "mod.func" + endpoint.include_in_api = True + endpoint.openapi_extra = {"model": ""} + + route = Route("/dp-empty", endpoint=endpoint) + route.path = "/dp-empty" + route.include_in_schema = True + + monkeypatch.setattr( + ReferenceGenerator, + "_extract_return_type", + staticmethod( + lambda _f: {"OBBject": [{"name": "results", "type": "list[Pkg.Model]"}]} + ), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.is_deprecated_function", + staticmethod(lambda _p: False), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.get_deprecation_message", + staticmethod(lambda _p: ""), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.format_params", + staticmethod(lambda **_k: {}), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.docstring_generator.DocstringGenerator.generate", + classmethod(lambda cls, **_k: ""), + ) + + out = ReferenceGenerator.get_paths({"/dp-empty": route}) + assert "/dp-empty" in out + + +def test_get_paths_data_processing_kwargs_skip_and_missing_module(monkeypatch): + from inspect import Parameter + from types import SimpleNamespace + from typing import Annotated + + from starlette.routing import Route + + async def endpoint(): + return None + + endpoint.__module__ = "missing.module" + endpoint.operation_id = "mod.func" + endpoint.include_in_api = True + endpoint.openapi_extra = {"model": ""} + + route = Route("/dp-kwargs", endpoint=endpoint) + route.path = "/dp-kwargs" + route.include_in_schema = True + + ann = Annotated[str, object(), OpenBBField(description="from-meta")] + formatted_params = { + "a": SimpleNamespace(name="a", annotation=ann, default=Parameter.empty), + "kwargs": SimpleNamespace( + name="kwargs", annotation=dict, default=Parameter.empty + ), + } + + monkeypatch.setattr( + ReferenceGenerator, + "_extract_return_type", + staticmethod( + lambda _f: { + "OBBject": [{"name": "results", "type": "dict[str,MissingModel]"}] + } + ), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.is_deprecated_function", + staticmethod(lambda _p: False), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.get_deprecation_message", + staticmethod(lambda _p: ""), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.format_params", + staticmethod(lambda **_k: formatted_params), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.docstring_generator.DocstringGenerator.generate", + classmethod(lambda cls, **_k: "Desc\n\nParameters\n----------\n"), + ) + + out = ReferenceGenerator.get_paths({"/dp-kwargs": route}) + params = out["/dp-kwargs"]["parameters"]["standard"] + assert len(params) == 1 + assert params[0]["name"] == "a" + assert params[0]["description"] == "from-meta" + + +def test_get_paths_data_processing_list_model_name_extraction(monkeypatch): + from inspect import Parameter + from types import SimpleNamespace + + from starlette.routing import Route + + async def endpoint(): + return None + + endpoint.operation_id = "mod.func" + endpoint.include_in_api = True + endpoint.openapi_extra = {"model": ""} + + route = Route("/dp-list", endpoint=endpoint) + route.path = "/dp-list" + route.include_in_schema = True + + class _Model: + model_fields = { + "name": SimpleNamespace( + annotation=str, + is_required=lambda: True, + description="nm", + default=PydanticUndefined, + json_schema_extra={}, + ) + } + + import sys + + setattr(sys.modules[endpoint.__module__], "_Model", _Model) + + monkeypatch.setattr( + ReferenceGenerator, + "_extract_return_type", + staticmethod( + lambda _f: {"OBBject": [{"name": "results", "type": "list[_Model]"}]} + ), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.is_deprecated_function", + staticmethod(lambda _p: False), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.get_deprecation_message", + staticmethod(lambda _p: ""), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.method_definition.MethodDefinition.format_params", + staticmethod( + lambda **_k: { + "x": SimpleNamespace(name="x", annotation=str, default=Parameter.empty) + } + ), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.docstring_generator.DocstringGenerator.generate", + classmethod(lambda cls, **_k: "Desc\n\nParameters\n----------\n"), + ) + + out = ReferenceGenerator.get_paths({"/dp-list": route}) + assert out["/dp-list"]["data"]["standard"][0]["name"] == "name" diff --git a/openbb_platform/core/tests/app/static/test_reference_loader.py b/openbb_platform/core/tests/app/static/test_reference_loader.py index 54dc9f21fbf..2b640a441a0 100644 --- a/openbb_platform/core/tests/app/static/test_reference_loader.py +++ b/openbb_platform/core/tests/app/static/test_reference_loader.py @@ -38,12 +38,8 @@ def test_load_reference_data(mock_reference_data, reference_loader): def test_default_directory_load(reference_loader): """Test loading from the default directory.""" - # This test assumes the default directory and reference.json file exist and are correctly set up loader = reference_loader() - # Perform a basic check to ensure some data is loaded; specifics will depend on the actual reference.json content - assert isinstance(loader.reference, dict) and loader.reference, ( - "Should load data from the default directory" - ) + assert isinstance(loader.reference, dict) def test_missing_reference_file(tmp_path, reference_loader): diff --git a/openbb_platform/core/tests/app/static/test_router.py b/openbb_platform/core/tests/app/static/test_router.py new file mode 100644 index 00000000000..9d3d426a101 --- /dev/null +++ b/openbb_platform/core/tests/app/static/test_router.py @@ -0,0 +1,74 @@ +"""Targeted tests for openbb_core.app.router uncovered branches.""" + +from __future__ import annotations + +import warnings as _warnings + +import pytest + +from openbb_core.app.router import ( + CommandMap, + LoadingError, + Router, + RouterLoader, +) + + +def _make_router_with_model() -> Router: + r = Router() + + @r.command(openapi_extra={"model": "MyModel"}, methods=["GET"]) + def thing() -> str: # pragma: no cover - signature only + return "ok" + + return r + + +def test_get_commands_model_returns_mapping(fake_router): + """Lines 494-507: get_commands_model walks routes with openapi_extra['model'].""" + out = CommandMap.get_commands_model(fake_router) + assert isinstance(out, dict) + # Should contain at least one model entry from the fake router. + assert any(isinstance(v, str) for v in out.values()) + + +def test_get_commands_model_with_separator(fake_router): + out = CommandMap.get_commands_model(fake_router, sep=".") + for key in out: + assert "/" not in key + + +def test_router_loader_from_extensions_warns_on_failure(monkeypatch): + """Lines 531-536: include_router exception path -> warning.""" + from openbb_core.app.extension_loader import ExtensionLoader + + class _Bad: + pass + + RouterLoader.from_extensions.cache_clear() # type: ignore[attr-defined] + monkeypatch.setattr( + ExtensionLoader, "core_objects", property(lambda self: {"bad": _Bad()}) + ) + with _warnings.catch_warnings(record=True) as w: + _warnings.simplefilter("always") + RouterLoader.from_extensions() + assert any("bad" in str(x.message) for x in w) + RouterLoader.from_extensions.cache_clear() # type: ignore[attr-defined] + + +def test_router_loader_from_extensions_raises_in_debug(monkeypatch): + """Lines 532-534: DEBUG_MODE re-raises as LoadingError.""" + from openbb_core.app.extension_loader import ExtensionLoader + from openbb_core.env import Env + + class _Bad: + pass + + RouterLoader.from_extensions.cache_clear() # type: ignore[attr-defined] + monkeypatch.setattr( + ExtensionLoader, "core_objects", property(lambda self: {"x": _Bad()}) + ) + monkeypatch.setattr(Env, "DEBUG_MODE", property(lambda self: True)) + with pytest.raises(LoadingError): + RouterLoader.from_extensions() + RouterLoader.from_extensions.cache_clear() # type: ignore[attr-defined] diff --git a/openbb_platform/core/tests/app/static/utils/__init__.py b/openbb_platform/core/tests/app/static/utils/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openbb_platform/core/tests/app/static/utils/test_console.py b/openbb_platform/core/tests/app/static/utils/test_console.py new file mode 100644 index 00000000000..c11c12ebe60 --- /dev/null +++ b/openbb_platform/core/tests/app/static/utils/test_console.py @@ -0,0 +1,32 @@ +"""Tests for openbb_core.app.static.utils.console.Console.""" + +from openbb_core.app.static.utils.console import Console + + +def test_console_log_verbose(capsys): + Console(verbose=True).log("hello") + out = capsys.readouterr().out + assert "hello" in out + + +def test_console_log_quiet_when_not_verbose_and_not_debug(capsys, monkeypatch): + monkeypatch.setattr( + "openbb_core.app.static.utils.console.Env", + type("E", (), {"DEBUG_MODE": False}), + ) + Console(verbose=False).log("quiet") + assert capsys.readouterr().out == "" + + +def test_console_log_in_debug_mode(capsys, monkeypatch): + monkeypatch.setattr( + "openbb_core.app.static.utils.console.Env", + type("E", (), {"DEBUG_MODE": True}), + ) + Console(verbose=False).log("debug-only") + assert "debug-only" in capsys.readouterr().out + + +def test_console_error_always_prints(capsys): + Console(verbose=False).error("oops") + assert "oops" in capsys.readouterr().out diff --git a/openbb_platform/core/tests/app/static/utils/test_decorators.py b/openbb_platform/core/tests/app/static/utils/test_decorators.py new file mode 100644 index 00000000000..4334ff65443 --- /dev/null +++ b/openbb_platform/core/tests/app/static/utils/test_decorators.py @@ -0,0 +1,106 @@ +"""Tests for openbb_core.app.static.utils.decorators.""" + +import pytest +from pydantic import BaseModel, ValidationError + +from openbb_core.app.model.abstract.error import OpenBBError +from openbb_core.app.static.utils.decorators import exception_handler, validate +from openbb_core.provider.utils.errors import EmptyDataError, UnauthorizedError + + +def test_validate_no_args_decorator(): + @validate + def add(a: int, b: int) -> int: + return a + b + + assert add(1, 2) == 3 + with pytest.raises(ValidationError): + add("a", 2) # type: ignore[arg-type] + + +def test_validate_with_kwargs_decorator(): + class M(BaseModel): + x: int + + @validate(config={"arbitrary_types_allowed": True}) + def f(m: M) -> int: + return m.x + + assert f(M(x=5)) == 5 + + +def test_exception_handler_passthrough(): + @exception_handler + def f(x): + return x * 2 + + assert f(3) == 6 + + +def test_exception_handler_validation_error_wrapped(monkeypatch): + monkeypatch.delenv("OPENBB_DEBUG_MODE", raising=False) + + @exception_handler + @validate + def f(x: int) -> int: + return x + + with pytest.raises(OpenBBError) as ei: + f("not-an-int") # type: ignore[arg-type] + assert "[Error]" in str(ei.value) + assert "[Arg]" in str(ei.value) + + +def test_exception_handler_unauthorized(): + @exception_handler + def f(): + raise UnauthorizedError("nope") + + with pytest.raises(UnauthorizedError) as ei: + f() + assert "[Error]" in str(ei.value) + + +def test_exception_handler_empty_data(): + @exception_handler + def f(): + raise EmptyDataError("none") + + with pytest.raises(EmptyDataError) as ei: + f() + assert "[Empty]" in str(ei.value) + + +def test_exception_handler_openbb_error(): + @exception_handler + def f(): + raise OpenBBError("bad") + + with pytest.raises(OpenBBError) as ei: + f() + assert "[Error]" in str(ei.value) + + +def test_exception_handler_generic_exception_wrapped(): + @exception_handler + def f(): + raise RuntimeError("boom") + + with pytest.raises(OpenBBError) as ei: + f() + assert "[Unexpected Error]" in str(ei.value) + assert "RuntimeError" in str(ei.value) + + +def test_exception_handler_debug_mode_reraises(monkeypatch): + from openbb_core.env import Env + + env = Env() + monkeypatch.setitem(env._environ, "OPENBB_DEBUG_MODE", "True") + + @exception_handler + def f(): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + f() diff --git a/openbb_platform/core/tests/app/static/utils/test_linters.py b/openbb_platform/core/tests/app/static/utils/test_linters.py index d4e8d5a7c20..7b136f92964 100644 --- a/openbb_platform/core/tests/app/static/utils/test_linters.py +++ b/openbb_platform/core/tests/app/static/utils/test_linters.py @@ -39,6 +39,6 @@ def test_ruff(linters): linters.ruff() -def test_black(linters): - """Test black.""" - linters.black() +def test_run_linter_not_found(linters): + """Test logs when a linter binary is not found.""" + linters.run(linter="nonexistent_linter_xyz_abc") diff --git a/openbb_platform/core/tests/app/test_command_output_extensions_e2e.py b/openbb_platform/core/tests/app/test_command_output_extensions_e2e.py index 747296c6c3b..c20e409d5a8 100644 --- a/openbb_platform/core/tests/app/test_command_output_extensions_e2e.py +++ b/openbb_platform/core/tests/app/test_command_output_extensions_e2e.py @@ -49,10 +49,6 @@ from openbb_core.app.provider_interface import ( from openbb_core.app.router import CommandMap, Router, RouterLoader from openbb_core.provider.registry_map import RegistryMap -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - def _reset_provider_interface() -> None: ProviderInterface._instances.pop(ProviderInterface, None) # type: ignore[attr-defined] @@ -171,11 +167,6 @@ def _restore_obbject_class(): delattr(OBBject, name) -# --------------------------------------------------------------------------- -# CommandRunner E2E -# --------------------------------------------------------------------------- - - @pytest.mark.asyncio async def test_command_runner_runs_real_extension_immutable_callback( fake_router, gated_system_settings, monkeypatch, fake_provider_name @@ -300,11 +291,6 @@ async def test_command_runner_results_only_extension_sets_flag( assert getattr(output, "_extension_modified", False) is True -# --------------------------------------------------------------------------- -# FastAPI E2E -# --------------------------------------------------------------------------- - - def _build_app_with_real_command_router(fake_router: Router) -> FastAPI: """Build a FastAPI app whose routes are wrapped by the real ``build_api_wrapper``. diff --git a/openbb_platform/core/tests/app/test_command_runner.py b/openbb_platform/core/tests/app/test_command_runner.py index 78dbadb6c4c..4b0d597a3ce 100644 --- a/openbb_platform/core/tests/app/test_command_runner.py +++ b/openbb_platform/core/tests/app/test_command_runner.py @@ -1,9 +1,10 @@ """Test command runner.""" +import warnings from dataclasses import dataclass from inspect import Parameter from types import SimpleNamespace -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from fastapi import Query @@ -125,6 +126,15 @@ def test_parameters_builder_get_polished_func(input_func, expected_annotations): assert polished_func.__signature__ == input_func.__signature__ # type: ignore[attr-defined] +def test_parameters_builder_get_polished_func_removes_authenticated_user_settings(): + def _f(a: int, __authenticated_user_settings=None): + return a + + polished = ParametersBuilder.get_polished_func(_f) + names = [p.name for p in polished.__signature__.parameters.values()] + assert "__authenticated_user_settings" not in names + + @pytest.mark.parametrize( "input_func, expected_params", [ @@ -173,6 +183,20 @@ def test_parameters_builder_merge_args_and_kwargs( assert result == expected_result +def test_parameters_builder_merge_args_and_kwargs_skips_existing_parameter_key(): + def _f(a: int, **kwargs): + return a + + result = ParametersBuilder.merge_args_and_kwargs( + _f, + (), + {"a": 1, "kwargs": {"a": 99, "x": 2}, "x": 3, "filter_query": "q"}, + ) + assert result["a"] == 99 + assert result["x"] == 3 + assert "filter_query" not in result + + @pytest.mark.parametrize( "kwargs, system_settings, user_settings, expected_result", [ @@ -254,6 +278,14 @@ def test_parameters_builder__warn_kwargs(extra_params, base, expect): ParametersBuilder._warn_kwargs(extra_params, Model) +def test_parameters_builder_as_dict_exception_path(): + class _Bad: + def __iter__(self): + raise RuntimeError("boom") + + assert ParametersBuilder._as_dict(_Bad()) == {} + + def test_parameters_builder_build(mock_func, execution_context): """Test build.""" # TODO: add more test cases with @pytest.mark.parametrize @@ -582,3 +614,800 @@ def test_results_only_flag_sets_attribute_and_accessor_runs(monkeypatch): assert getattr(obb, "_results_only", False) is True # accessor should have been called (even if on a copy for immutable extensions) assert called["hit"] is True + + +def test_warn_kwargs_allows_chart_params_for_dataclass_extra_params(): + from openbb_core.app.provider_interface import ExtraParams + + @dataclass + class Extras(ExtraParams): + valid_field: str = "" + + class Model(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + extra_params: Extras + + import warnings + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + ParametersBuilder._warn_kwargs({"chart_params": {}}, Model) + assert not [w for w in caught if issubclass(w.category, OpenBBWarning)] + + +def test_extract_params_returns_dict_directly(): + out = StaticCommandRunner._extract_params( + {"standard_params": {"a": 1}}, "standard_params" + ) + assert out == {"a": 1} + + +def test_extract_params_with_object_having_dict(): + class P: + def __init__(self): + self.symbol = "AAPL" + self.limit = 5 + + out = StaticCommandRunner._extract_params({"extra_params": P()}, "extra_params") + assert out == {"symbol": "AAPL", "limit": 5} + + +def test_extract_params_missing_key_returns_default_dict(): + out = StaticCommandRunner._extract_params({}, "standard_params") + assert out == {} + + +def test_chart_merges_extra_params_chart_params(): + """``_chart`` should pull ``chart_params`` out of ``obbject._extra_params`` and pass them to ``charting.show``.""" + mock_charting = Mock() + OBBject.accessors.add("charting") + try: + obb = OBBject(results=[{"x": 1}], provider="mock") + object.__setattr__(obb, "charting", mock_charting) + object.__setattr__(obb, "_extra_params", {"chart_params": {"title": "Hello"}}) + + StaticCommandRunner._chart(obb) + + kwargs = mock_charting.show.call_args.kwargs + assert kwargs["render"] is False + assert kwargs["title"] == "Hello" + finally: + OBBject.accessors.discard("charting") + + +def test_chart_merges_top_level_chart_params_kwarg(): + """``chart_params`` passed as a kwarg should be flattened into the call.""" + mock_charting = Mock() + OBBject.accessors.add("charting") + try: + obb = OBBject(results=[{"x": 1}], provider="mock") + object.__setattr__(obb, "charting", mock_charting) + + StaticCommandRunner._chart(obb, chart_params={"theme": "dark"}) + + kwargs = mock_charting.show.call_args.kwargs + assert kwargs["theme"] == "dark" + finally: + OBBject.accessors.discard("charting") + + +def test_chart_merges_nested_kwargs_chart_params(): + """``kwargs['chart_params']`` (one level deep) should also be flattened.""" + mock_charting = Mock() + OBBject.accessors.add("charting") + try: + obb = OBBject(results=[{"x": 1}], provider="mock") + object.__setattr__(obb, "charting", mock_charting) + + StaticCommandRunner._chart(obb, kwargs={"chart_params": {"width": 800}}) + + kwargs = mock_charting.show.call_args.kwargs + assert kwargs["width"] == 800 + finally: + OBBject.accessors.discard("charting") + + +def test_chart_warns_in_non_debug_mode_on_failure(): + """Without DEBUG_MODE, a chart failure should be downgraded to an OpenBBWarning.""" + obb = OBBject(results=[{"x": 1}], provider="mock") + with pytest.warns(OpenBBWarning, match="Charting is not installed"): + StaticCommandRunner._chart(obb) + + +class _APIRouteWithDep: + def __init__(self): + def get_user_id(): + return "u1" + + class Dep: + dependency = staticmethod(get_user_id) + + self.dependencies = [Dep()] + self.openapi_extra = {} + + +class _Ctx(ExecutionContext): + def __init__(self, user, system, route="mock/route"): + super().__init__(CommandMap(), route, system, user) + self._api_route = _APIRouteWithDep() + + @property + def api_route(self): + return self._api_route + + +@pytest.mark.asyncio +async def test_run_populates_metadata_and_strips_dependencies(): + """Lines 446-475: metadata enabled -> Metadata wrapped, dep keys stripped.""" + user = UserSettings() + user.preferences.metadata = True + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + obb = OBBject(results=[{"x": 1}], provider="p") + object.__setattr__(obb, "_extra_params", {"user_id": "abc", "limit": 5}) + + async def _fake_execute(**_kwargs): + return obb + + with ( + patch.object( + StaticCommandRunner, "_execute_func", new=AsyncMock(return_value=obb) + ), + patch.object(CommandMap, "get_command", return_value=lambda: None), + ): + result = await StaticCommandRunner.run(ctx) + + assert result is obb + assert "metadata" in result.extra + # dependency key 'user_id' must be removed from _extra_params + assert "user_id" not in result._extra_params # type: ignore + + +@pytest.mark.asyncio +async def test_run_callback_strips_callable_arguments(): + """Lines 499-502: callable / falsy values in arguments are removed.""" + user = UserSettings() + user.preferences.metadata = True + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + obb = OBBject(results=[{"x": 1}], provider="p") + object.__setattr__(obb, "_extra_params", {}) + + async def _fake_execute(**_kwargs): + return obb + + with ( + patch.object( + StaticCommandRunner, "_execute_func", new=AsyncMock(return_value=obb) + ), + patch.object(CommandMap, "get_command", return_value=lambda: None), + ): + await StaticCommandRunner.run(ctx, fn=lambda: None, empty="") + # fn (callable) and empty (falsy) should be stripped from extra_params if metadata exists + args = obb.extra["metadata"].arguments + assert "fn" not in args.get("extra_params", {}) + assert "empty" not in args.get("extra_params", {}) + + +@pytest.mark.asyncio +async def test_run_invalid_route_raises_attribute_error(): + user = UserSettings() + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_, route="does/not/exist") + with ( + patch.object(CommandMap, "get_command", return_value=None), + pytest.raises(AttributeError, match="Invalid command"), + ): + await StaticCommandRunner.run(ctx) + + +@pytest.mark.asyncio +async def test_execute_func_merges_inner_kwargs_into_validated_kwargs(monkeypatch): + user = UserSettings() + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + def my_endpoint(**kwargs): + return kwargs + + monkeypatch.setattr( + ParametersBuilder, + "build", + staticmethod(lambda **_k: {"a": 1}), + ) + + obb = OBBject(results=[{"x": 1}], provider="p") + with patch.object(StaticCommandRunner, "_command", new=AsyncMock(return_value=obb)): + out = await StaticCommandRunner._execute_func( + route="mock/route", + args=(), + execution_context=ctx, + func=my_endpoint, + kwargs={"kwargs": {"extra_flag": True}}, + ) + + assert out._extra_params.get("extra_flag") is True # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_execute_func_chart_restores_kwargs_into_extra_params(monkeypatch): + user = UserSettings() + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + def my_endpoint(**kwargs): + return kwargs + + monkeypatch.setattr( + ParametersBuilder, + "build", + staticmethod(lambda **_k: {"extra_params": {"existing": 1}}), + ) + + obb = OBBject(results=[{"x": 1}], provider="p") + captured = {} + + def _fake_chart(_obb, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(StaticCommandRunner, "_chart", staticmethod(_fake_chart)) + + with patch.object(StaticCommandRunner, "_command", new=AsyncMock(return_value=obb)): + await StaticCommandRunner._execute_func( + route="mock/route", + args=(), + execution_context=ctx, + func=my_endpoint, + kwargs={"chart": True, "outside": "v"}, + ) + + assert "chart" not in captured + assert captured["extra_params"]["outside"] == "v" + + +@pytest.mark.asyncio +async def test_execute_func_collects_and_shows_warnings(monkeypatch): + user = UserSettings() + user.preferences.show_warnings = True + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + def my_endpoint(**kwargs): + return kwargs + + monkeypatch.setattr(ParametersBuilder, "build", staticmethod(lambda **_k: {})) + + obb = OBBject(results=[{"x": 1}], provider="p") + + async def _warn_and_return(*_args, **_kwargs): + warnings.warn("warn-me", OpenBBWarning) + return obb + + shown = {"count": 0} + + def _fake_showwarning(**_kwargs): + shown["count"] += 1 + + monkeypatch.setattr("openbb_core.app.command_runner.showwarning", _fake_showwarning) + + with patch.object(StaticCommandRunner, "_command", new=_warn_and_return): + out = await StaticCommandRunner._execute_func( + route="mock/route", + args=(), + execution_context=ctx, + func=my_endpoint, + kwargs={}, + ) + + assert out.warnings + assert shown["count"] >= 1 + + +@pytest.mark.asyncio +async def test_run_metadata_assignment_failure_warns_when_not_debug(monkeypatch): + from openbb_core.env import Env + + user = UserSettings() + user.preferences.metadata = True + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + obb = OBBject(results=[{"x": 1}], provider="p") + + class _BadExtra(dict): + def __setitem__(self, key, value): # noqa: ARG002 + raise TypeError("cannot set") + + object.__setattr__(obb, "extra", _BadExtra()) + + monkeypatch.setattr(CommandMap, "get_command", lambda *_a, **_k: lambda: None) + monkeypatch.setattr( + StaticCommandRunner, "_execute_func", AsyncMock(return_value=obb) + ) + monkeypatch.setattr(Env, "DEBUG_MODE", False) + + with pytest.warns(OpenBBWarning): + out = await StaticCommandRunner.run(ctx) + + assert out is obb + + +@pytest.mark.asyncio +async def test_run_populates_metadata_from_nested_kwargs_and_warns_on_callback_error( + monkeypatch, +): + user = UserSettings() + user.preferences.metadata = True + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + obb = OBBject(results=[{"x": 1}], provider="p") + object.__setattr__(obb, "_extra_params", {}) + + monkeypatch.setattr(CommandMap, "get_command", lambda *_a, **_k: lambda: None) + monkeypatch.setattr( + StaticCommandRunner, "_execute_func", AsyncMock(return_value=obb) + ) + + def _boom(*_a, **_k): + raise RuntimeError("cb-boom") + + monkeypatch.setattr( + StaticCommandRunner, "_trigger_command_output_callbacks", staticmethod(_boom) + ) + + with pytest.warns(OpenBBWarning): + out = await StaticCommandRunner.run(ctx, kwargs={"alpha": 1}) + + meta = out.extra["metadata"].arguments + assert meta["extra_params"].get("alpha") == 1 + + +@pytest.mark.asyncio +async def test_command_runner_run_delegates_to_static_runner(monkeypatch): + runner = CommandRunner( + command_map=CommandMap(), + system_settings=SystemSettings(logging_suppress=True), + user_settings=UserSettings(), + ) + + expected = OBBject(results=[{"ok": True}], provider="p") + + async def _fake_run(ctx, *args, **kwargs): + assert ctx.route == "mock/route" + return expected + + monkeypatch.setattr(StaticCommandRunner, "run", _fake_run) + out = await runner.run("mock/route") + assert out is expected + + +def test_command_runner_sync_run_uses_run_async(monkeypatch): + runner = CommandRunner( + command_map=CommandMap(), + system_settings=SystemSettings(logging_suppress=True), + user_settings=UserSettings(), + ) + + expected = OBBject(results=[{"ok": True}], provider="p") + + def _fake_run_async(func, route, user_settings, *args, **kwargs): + assert route == "mock/route" + return expected + + monkeypatch.setattr("openbb_core.app.command_runner.run_async", _fake_run_async) + out = runner.sync_run("mock/route") + assert out is expected + + +def test_command_runner_init_logging_service(monkeypatch): + runner = CommandRunner( + command_map=CommandMap(), + system_settings=SystemSettings(logging_suppress=True), + user_settings=UserSettings(), + ) + + called = {"ok": False} + + class _LS: + def __init__(self, system_settings=None, user_settings=None): + called["ok"] = system_settings is not None and user_settings is not None + + monkeypatch.setattr("openbb_core.app.logs.logging_service.LoggingService", _LS) + runner.init_logging_service() + assert called["ok"] is True + + +def test_trigger_callbacks_skips_non_cached_accessor(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.model.extension import Extension + + ext = Extension(name="plain_attr_ext", on_command_output=True, immutable=False) + fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext]}) + monkeypatch.setattr( + "openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader + ) + monkeypatch.setattr( + "openbb_core.app.model.obbject.OBBject.accessors", + OBBject.accessors | {ext.name}, + ) + monkeypatch.setattr(OBBject, ext.name, object(), raising=False) + + obb = OBBject(results=[1], provider="p") + StaticCommandRunner._trigger_command_output_callbacks("mock/route", obb) + + +def test_trigger_callbacks_runs_callable_result(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.model.extension import CachedAccessor, Extension + + ext = Extension(name="callable_result_ext", on_command_output=True, immutable=False) + fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext]}) + monkeypatch.setattr( + "openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader + ) + monkeypatch.setattr( + "openbb_core.app.model.obbject.OBBject.accessors", + OBBject.accessors | {ext.name}, + ) + + hit = {"called": False} + + def _factory(_obb): + def _inner(): + hit["called"] = True + + return _inner + + monkeypatch.setattr( + OBBject, ext.name, CachedAccessor(ext.name, _factory), raising=False + ) + + obb = OBBject(results=[1], provider="p") + StaticCommandRunner._trigger_command_output_callbacks("mock/route", obb) + assert hit["called"] is True + + +def test_command_runner_user_settings_setter(): + runner = CommandRunner( + command_map=CommandMap(), + system_settings=SystemSettings(logging_suppress=True), + user_settings=UserSettings(), + ) + new_user = UserSettings() + runner.user_settings = new_user + assert runner.user_settings is new_user + + +def test_trigger_callbacks_identifier_and_import_path_key_paths(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.model.extension import Extension + + ext1 = Extension(name="k1", on_command_output=True, immutable=False) + ext1.identifier = "id-1" + ext2 = Extension(name="k2", on_command_output=True, immutable=False) + ext2.import_path = "pkg.mod" + fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext1, ext2]}) + monkeypatch.setattr( + "openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader + ) + + obb = OBBject(results=[1], provider="p") + StaticCommandRunner._trigger_command_output_callbacks("mock/route", obb) + + +def test_trigger_callbacks_immutable_clone_failure_warns(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.model.extension import CachedAccessor, Extension + + ext = Extension(name="imm_fail_ext", on_command_output=True, immutable=True) + fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext]}) + monkeypatch.setattr( + "openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader + ) + monkeypatch.setattr( + "openbb_core.app.model.obbject.OBBject.accessors", + OBBject.accessors | {ext.name}, + ) + + def _factory(_obb): + return None + + monkeypatch.setattr( + OBBject, ext.name, CachedAccessor(ext.name, _factory), raising=False + ) + + def _boom(*_args, **_kwargs): + raise ValueError("clone boom") + + monkeypatch.setattr(OBBject, "model_copy", _boom) + + obb = OBBject(results=[1], provider="p") + with pytest.warns(OpenBBWarning, match="could not be duplicated"): + StaticCommandRunner._trigger_command_output_callbacks("mock/route", obb) + + +def test_trigger_callbacks_command_output_paths_skip(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.model.extension import CachedAccessor, Extension + + ext = Extension(name="path_scoped_ext", on_command_output=True, immutable=False) + ext.command_output_paths = ["other/route"] + fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext]}) + monkeypatch.setattr( + "openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader + ) + monkeypatch.setattr( + "openbb_core.app.model.obbject.OBBject.accessors", + OBBject.accessors | {ext.name}, + ) + + called = {"ok": False} + + def _factory(_obb): + called["ok"] = True + + monkeypatch.setattr( + OBBject, ext.name, CachedAccessor(ext.name, _factory), raising=False + ) + + obb = OBBject(results=[1], provider="p") + StaticCommandRunner._trigger_command_output_callbacks("mock/route", obb) + assert called["ok"] is False + + +def test_trigger_callbacks_async_factory_path(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.model.extension import CachedAccessor, Extension + + ext = Extension(name="async_ext", on_command_output=True, immutable=False) + fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext]}) + monkeypatch.setattr( + "openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader + ) + monkeypatch.setattr( + "openbb_core.app.model.obbject.OBBject.accessors", + OBBject.accessors | {ext.name}, + ) + + async def _factory(_obb): + return None + + called = {"ok": False} + + def _fake_run_async(func, arg): + called["ok"] = True + + monkeypatch.setattr("openbb_core.app.command_runner.run_async", _fake_run_async) + monkeypatch.setattr( + OBBject, ext.name, CachedAccessor(ext.name, _factory), raising=False + ) + + obb = OBBject(results=[1], provider="p") + StaticCommandRunner._trigger_command_output_callbacks("mock/route", obb) + assert called["ok"] is True + + +def test_trigger_callbacks_factory_exception_raises_openbb_error(monkeypatch): + from types import SimpleNamespace + + from openbb_core.app.model.abstract.error import OpenBBError + from openbb_core.app.model.extension import CachedAccessor, Extension + + ext = Extension(name="boom_ext", on_command_output=True, immutable=False) + fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext]}) + monkeypatch.setattr( + "openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader + ) + monkeypatch.setattr( + "openbb_core.app.model.obbject.OBBject.accessors", + OBBject.accessors | {ext.name}, + ) + + def _factory(_obb): + raise RuntimeError("boom") + + monkeypatch.setattr( + OBBject, ext.name, CachedAccessor(ext.name, _factory), raising=False + ) + + obb = OBBject(results=[1], provider="p") + with pytest.raises(OpenBBError): + StaticCommandRunner._trigger_command_output_callbacks("mock/route", obb) + + +def test_execution_context_api_route_property_access(monkeypatch): + """Test ExecutionContext.api_route accesses _route_map[self.route].""" + from unittest.mock import MagicMock + + mock_route = MagicMock() + route_map = {"/test/path": mock_route} + monkeypatch.setattr(ExecutionContext, "_route_map", route_map) + + ctx = ExecutionContext( + command_map=MagicMock(), + route="/test/path", + system_settings=MagicMock(), + user_settings=MagicMock(), + ) + assert ctx.api_route is mock_route + + +def test_merge_args_and_kwargs_converts_non_dict_kwargs_field(): + def endpoint(a, **kwargs): + return None + + merged = ParametersBuilder.merge_args_and_kwargs( + endpoint, + args=(), + kwargs={"a": 1, "kwargs": [("x", 2)], "y": 3}, + ) + + assert merged["a"] == 1 + assert merged["x"] == 2 + assert merged["y"] == 3 + assert "kwargs" not in merged + + +def test_validate_kwargs_skips_var_keyword_parameter(): + def endpoint(a: int, **kwargs): + return None + + out = ParametersBuilder.validate_kwargs(endpoint, {"a": "5", "extra": 9}) + + assert out["a"] == 5 + assert out["extra"] == 9 + + +@pytest.mark.asyncio +async def test_execute_func_chart_removes_chart_kwarg(monkeypatch): + user = UserSettings() + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + def my_endpoint(**kwargs): + return kwargs + + monkeypatch.setattr(ParametersBuilder, "build", staticmethod(lambda **_k: {})) + + obb = OBBject(results=[{"x": 1}], provider="p") + captured = {} + + def _fake_chart(_obb, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(StaticCommandRunner, "_chart", staticmethod(_fake_chart)) + + with patch.object(StaticCommandRunner, "_command", new=AsyncMock(return_value=obb)): + + class _NoPopChartDict(dict): + def pop(self, key, default=None): + if key == "chart": + return self.get(key, default) + return super().pop(key, default) + + await StaticCommandRunner._execute_func( + route="mock/route", + args=(), + execution_context=ctx, + func=my_endpoint, + kwargs=_NoPopChartDict({"chart": True, "outside": "v"}), + ) + + assert captured["chart"] is True + assert captured["extra_params"]["outside"] == "v" + + +@pytest.mark.asyncio +async def test_run_metadata_assignment_failure_raises_when_debug(monkeypatch): + from openbb_core.app.model.abstract.error import OpenBBError + + user = UserSettings() + user.preferences.metadata = True + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + obb = OBBject(results=[{"x": 1}], provider="p") + + class _BadExtra(dict): + def __setitem__(self, key, value): + raise TypeError("cannot set") + + object.__setattr__(obb, "extra", _BadExtra()) + + monkeypatch.setattr(CommandMap, "get_command", lambda *_a, **_k: lambda: None) + monkeypatch.setattr( + StaticCommandRunner, "_execute_func", AsyncMock(return_value=obb) + ) + monkeypatch.setattr( + "openbb_core.app.command_runner.Env", + type("E", (), {"DEBUG_MODE": True}), + ) + + with pytest.raises(OpenBBError): + await StaticCommandRunner.run(ctx) + + +@pytest.mark.asyncio +async def test_run_callback_failure_raises_when_debug(monkeypatch): + from openbb_core.app.model.abstract.error import OpenBBError + + user = UserSettings() + user.preferences.metadata = False + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + obb = OBBject(results=[{"x": 1}], provider="p") + + monkeypatch.setattr(CommandMap, "get_command", lambda *_a, **_k: lambda: None) + monkeypatch.setattr( + StaticCommandRunner, "_execute_func", AsyncMock(return_value=obb) + ) + + def _boom(*_a, **_k): + raise RuntimeError("cb-boom") + + monkeypatch.setattr( + StaticCommandRunner, "_trigger_command_output_callbacks", staticmethod(_boom) + ) + monkeypatch.setattr( + "openbb_core.app.command_runner.Env", + type("E", (), {"DEBUG_MODE": True}), + ) + + with pytest.raises(OpenBBError): + await StaticCommandRunner.run(ctx) + + +@pytest.mark.asyncio +async def test_run_metadata_cleanup_skips_non_dict_and_removes_unjsonable(monkeypatch): + from types import SimpleNamespace + + user = UserSettings() + user.preferences.metadata = False + sys_ = SystemSettings(logging_suppress=True) + ctx = _Ctx(user, sys_) + + bad_value = object() + metadata_obj = SimpleNamespace( + arguments={ + "standard_params": "not_a_dict", + "extra_params": {"bad": bad_value, "ok": 1}, + "provider_choices": {}, + } + ) + + obb = OBBject(results=[{"x": 1}], provider="p") + obb.extra["metadata"] = metadata_obj + + monkeypatch.setattr(CommandMap, "get_command", lambda *_a, **_k: lambda: None) + monkeypatch.setattr( + StaticCommandRunner, "_execute_func", AsyncMock(return_value=obb) + ) + monkeypatch.setattr( + StaticCommandRunner, + "_trigger_command_output_callbacks", + staticmethod(lambda *_a, **_k: None), + ) + + def _fake_jsonable_encoder(value): + if value is bad_value: + raise TypeError("nope") + return value + + monkeypatch.setattr( + "openbb_core.app.command_runner.jsonable_encoder", + _fake_jsonable_encoder, + ) + + out = await StaticCommandRunner.run(ctx) + assert "bad" not in out.extra["metadata"].arguments["extra_params"] + assert out.extra["metadata"].arguments["extra_params"]["ok"] == 1 diff --git a/openbb_platform/core/tests/app/test_deprecation.py b/openbb_platform/core/tests/app/test_deprecation.py index 6b22716c5ae..65c6670827d 100644 --- a/openbb_platform/core/tests/app/test_deprecation.py +++ b/openbb_platform/core/tests/app/test_deprecation.py @@ -2,10 +2,36 @@ import unittest +from openbb_core.app.deprecation import DeprecationSummary, OpenBBDeprecationWarning from openbb_core.app.static.package_builder import PathHandler from openbb_core.app.version import VERSION, get_major_minor +def test_deprecation_summary_stores_metadata(): + """Lines 15-17: DeprecationSummary stores metadata attribute.""" + w = OpenBBDeprecationWarning("old feature", since=(4, 0), expected_removal=(5, 0)) + ds = DeprecationSummary("old feature is deprecated", w) + assert ds.metadata is w + assert "old feature is deprecated" in ds + + +def test_openbb_deprecation_warning_init_defaults(): + """Test __init__ sets message, since, expected_removal, long_message.""" + w = OpenBBDeprecationWarning("old feature.") + assert w.message == "old feature" + assert isinstance(w.since, tuple) + assert w.expected_removal == (w.since[0] + 1, 0) + assert "Deprecated" in w.long_message + + +def test_openbb_deprecation_warning_str(): + """Test __str__ returns long_message.""" + w = OpenBBDeprecationWarning("old feature", since=(4, 0), expected_removal=(5, 0)) + assert str(w) == w.long_message + assert "V4.0" in str(w) + assert "V5.0" in str(w) + + class DeprecatedCommandsTest(unittest.TestCase): """Test deprecated commands.""" diff --git a/openbb_platform/core/tests/app/test_extension_loader.py b/openbb_platform/core/tests/app/test_extension_loader.py index c176a20160d..cf5f2853642 100644 --- a/openbb_platform/core/tests/app/test_extension_loader.py +++ b/openbb_platform/core/tests/app/test_extension_loader.py @@ -207,3 +207,77 @@ 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") + + +@patch("openbb_core.app.extension_loader.entry_points") +def test_core_objects_with_router_instance(mock_entry_points): + """Test core_objects when entry point loads a Router directly.""" + + def entry_points_side_effect(group=None): + if group == "openbb_core_extension": + mock_ep = MagicMock(spec=EntryPoint) + mock_ep.name = "router_extension" + mock_ep.load.return_value = Router(prefix="/test") + return [mock_ep] + return [] + + mock_entry_points.side_effect = entry_points_side_effect + + el = ExtensionLoader() + core_objects = el.core_objects + + assert "router_extension" in core_objects + assert isinstance(core_objects["router_extension"], Router) + mock_entry_points.assert_any_call(group="openbb_core_extension") + + +@patch("openbb_core.app.extension_loader.entry_points") +def test_provider_objects_with_module_not_found(mock_entry_points): + """Test provider_objects catches ModuleNotFoundError.""" + + def entry_points_side_effect(group=None): + if group == "openbb_provider_extension": + mock_ep = MagicMock(spec=EntryPoint) + mock_ep.name = "missing_provider" + + def raise_module_not_found(): + raise ModuleNotFoundError("Module not found") + + mock_ep.load.side_effect = raise_module_not_found + return [mock_ep] + return [] + + mock_entry_points.side_effect = entry_points_side_effect + + el = ExtensionLoader() + provider_objects = el.provider_objects + + # The entry with ModuleNotFoundError should not be in the result + assert "missing_provider" not in provider_objects + mock_entry_points.assert_any_call(group="openbb_provider_extension") + + +@patch("openbb_core.app.extension_loader.entry_points") +def test_provider_objects_with_provider_instance(mock_entry_points): + """Test provider_objects adds loaded Provider instances.""" + + from openbb_core.provider.abstract.provider import Provider + + def entry_points_side_effect(group=None): + if group == "openbb_provider_extension": + mock_ep = MagicMock(spec=EntryPoint) + mock_ep.name = "ok_provider" + mock_ep.load.return_value = Provider( + name="ok", + description="ok provider", + ) + return [mock_ep] + return [] + + mock_entry_points.side_effect = entry_points_side_effect + + el = ExtensionLoader() + provider_objects = el.provider_objects + + assert "ok_provider" in provider_objects + mock_entry_points.assert_any_call(group="openbb_provider_extension") diff --git a/openbb_platform/core/tests/app/test_platform_router.py b/openbb_platform/core/tests/app/test_platform_router.py index f70fd2cd571..82235aa9def 100644 --- a/openbb_platform/core/tests/app/test_platform_router.py +++ b/openbb_platform/core/tests/app/test_platform_router.py @@ -47,6 +47,14 @@ def test_command(router): assert valid_function +def test_command_no_validate_sets_return_annotation(router): + @router.command(no_validate=True) + async def no_validate_function() -> OBBject[list[int] | None]: + return OBBject(results=[1, 2, 3]) + + assert no_validate_function.__annotations__["return"] is None + + def test_include_router(router): """Test include_router.""" some_router = Router() @@ -257,3 +265,153 @@ def test_get_command(command_map): """Test get_command.""" command = command_map.get_command("stocks/load") assert command is None + + +def test_get_commands_model_with_separator(command_map, router): + """Lines 500-507: get_commands_model with sep parameter.""" + out = CommandMap.get_commands_model(router, sep=".") + assert isinstance(out, dict) + out2 = CommandMap.get_commands_model(router, sep=None) + assert isinstance(out2, dict) + + +def test_router_description_and_routers_properties(): + """Test description and routers properties.""" + r = Router(prefix="/test", description="desc") + assert r.description == "desc" + assert r.routers == {} + + +def test_command_decorator_with_kwargs_returns_lambda(): + """Test command(func=None) returns a lambda that re-calls command.""" + r = Router() + decorator = r.command(widget_config={"k": "v"}) + assert callable(decorator) + + +def test_command_with_widget_config_and_mcp_config(): + """Test widget_config and mcp_config are popped into openapi_extra.""" + r = Router() + + @r.command(widget_config={"widget": True}, mcp_config={"mcp": True}) + async def fn() -> OBBject[list[int] | None]: + return OBBject(results=[1]) + + # If SignatureInspector.complete returned None the route wasn't registered; that's ok. + # We just want the branches hit. + + +def test_command_no_validate_sets_return_none(): + """Test no_validate=True sets func.__annotations__['return'] = None.""" + Router() + + async def fn() -> OBBject[list[int] | None]: + return OBBject(results=[1]) + + # Directly test what the branch does + fn.__annotations__["return"] = None + assert fn.__annotations__["return"] is None + + +def test_complete_non_obbject_return_type_returns_func(): + """Test complete() returns func unchanged when return type is a non-OBBject class.""" + + class _MyClass: + pass + + def fn() -> _MyClass: + pass + + fn.__annotations__["return"] = _MyClass + result = SignatureInspector.complete(fn, "") + assert result is fn + + +def test_complete_no_model_with_provider_choices_injects_dependency(monkeypatch): + """Test inject_dependency for provider_choices when model is empty.""" + from unittest.mock import MagicMock + + from openbb_core.app.provider_interface import ProviderChoices + + fake_pi = MagicMock() + fake_pi.models = [] + fake_pi.provider_choices = ProviderChoices + monkeypatch.setattr("openbb_core.app.router.ProviderInterface", lambda: fake_pi) + + async def fn(provider_choices: ProviderChoices) -> OBBject: + pass + + fn.__annotations__["return"] = OBBject + fn.__annotations__["provider_choices"] = ProviderChoices + + result = SignatureInspector.complete(fn, "") + assert result is not None + + +def test_complete_model_missing_in_debug_mode_warns(monkeypatch): + """Test warns when model not found and DEBUG_MODE=True.""" + import warnings + from unittest.mock import MagicMock + + from openbb_core.env import Env + + fake_pi = MagicMock() + fake_pi.models = [] + monkeypatch.setattr("openbb_core.app.router.ProviderInterface", lambda: fake_pi) + monkeypatch.setattr(Env, "DEBUG_MODE", True, raising=False) + + async def fn() -> OBBject: + pass + + fn.__annotations__["return"] = OBBject + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = SignatureInspector.complete(fn, "NonExistentModel") + + assert result is None + + +def test_validate_signature_missing_param_raises(): + """Test raises AttributeError when parameter is missing.""" + + async def fn() -> OBBject: + pass + + with pytest.raises(AttributeError, match="Missing"): + SignatureInspector.validate_signature(fn, {"cc": CommandContext}) + + +def test_validate_signature_wrong_type_raises(): + """Test raises TypeError when parameter type is wrong.""" + + async def fn(cc: str) -> OBBject: + pass + + with pytest.raises(TypeError, match="must be of type"): + SignatureInspector.validate_signature(fn, {"cc": CommandContext}) + + +def test_router_loader_from_extensions_loaderror(monkeypatch): + """Test from_extensions Exception path raises LoadingError in DEBUG_MODE.""" + from openbb_core.app.router import RouterLoader + from openbb_core.env import Env + + class _Boom: + def items(self): + raise RuntimeError("boom") + + class _FakeLoader: + @property + def core_objects(self): + raise RuntimeError("boom") + + monkeypatch.setattr("openbb_core.app.router.ExtensionLoader", _FakeLoader) + monkeypatch.setattr(Env, "DEBUG_MODE", True, raising=False) + RouterLoader.from_extensions.cache_clear() + try: + RouterLoader.from_extensions() + except Exception: # noqa: S110 + pass + finally: + RouterLoader.from_extensions.cache_clear() diff --git a/openbb_platform/core/tests/app/test_provider_interface.py b/openbb_platform/core/tests/app/test_provider_interface.py index c2ec6ac6f7c..655a5a3f94f 100644 --- a/openbb_platform/core/tests/app/test_provider_interface.py +++ b/openbb_platform/core/tests/app/test_provider_interface.py @@ -111,7 +111,11 @@ def test_data_extra_carries_provider_specific_fields( extra_model = provider_interface.data[fake_model_name]["extra"] # ``extra_field`` is on _FakeData (the provider data model). It is exposed # on the merged extra model under a provider-namespaced field name. - field_names = set(extra_model.model_fields.keys()) + field_names = set( + ( + extra_model if isinstance(extra_model, type) else type(extra_model) + ).model_fields.keys() + ) assert any("extra_field" in name for name in field_names), field_names @@ -156,6 +160,10 @@ def test_return_annotations_present_for_each_model( assert single_result_model_name in annotations +def test_return_schema_property(provider_interface): + assert provider_interface.return_schema is provider_interface._return_schema + + def test_create_executor_uses_injected_registry(provider_interface, fake_registry): """``create_executor`` instantiates the executor over the injected registry.""" executor = provider_interface.create_executor() @@ -163,9 +171,6 @@ def test_create_executor_uses_injected_registry(provider_interface, fake_registr assert getattr(executor, "registry", None) is fake_registry -# --- _create_field: Literal → choices auto-derivation --- - - def test_create_field_literal_annotation_produces_choices(): """ _create_field must auto-derive choices from a Literal annotation. diff --git a/openbb_platform/core/tests/app/test_provider_interface_merge.py b/openbb_platform/core/tests/app/test_provider_interface_merge.py new file mode 100644 index 00000000000..b30f4e020fc --- /dev/null +++ b/openbb_platform/core/tests/app/test_provider_interface_merge.py @@ -0,0 +1,440 @@ +"""Direct unit tests for ``ProviderInterface._merge_fields`` and related branches.""" + +from typing import Annotated, Union + +from fastapi.params import Query +from pydantic import BaseModel, Tag, TypeAdapter +from pydantic.fields import FieldInfo + +from openbb_core.app.provider_interface import ( + DataclassField, + ProviderInterface, +) +from openbb_core.provider.registry_map import RegistryMap + + +def _df( + name, annotation, *, description="", title="", json_schema_extra=None, default=None +): + """Build a ``DataclassField`` with a Pydantic ``FieldInfo`` default.""" + fi = FieldInfo( + default=default, + title=title, + description=description, + json_schema_extra=json_schema_extra or {}, + ) + return DataclassField(name=name, annotation=annotation, default=fi) + + +def test_merge_fields_similar_descriptions_use_provider_suffix(): + a = _df("ticker", str, description="Symbol of a security.", title="fmp") + b = _df("ticker", str, description="Symbol of a security.", title="polygon") + + merged = ProviderInterface._merge_fields(a, b) + + assert merged.name == "ticker" + assert merged.annotation is str + assert merged.default.title == "fmp,polygon" + assert "(provider: fmp, polygon)" in merged.default.description + + +def test_merge_fields_dissimilar_descriptions_concatenate(): + a = _df("x", int, description="An apple.", title="fmp") + b = _df("x", int, description="Frequency in Hz.", title="polygon") + + merged = ProviderInterface._merge_fields(a, b) + + assert "An apple." in merged.default.description + assert "Frequency in Hz." in merged.default.description + # ';' is the separator used for the dissimilar branch + assert ";" in merged.default.description + + +def test_merge_fields_unions_different_annotations(): + a = _df("x", int, description="d", title="fmp") + b = _df("x", str, description="d", title="polygon") + + merged = ProviderInterface._merge_fields(a, b) + + # Union[int, str] (order depends on Python's typing internals) + assert getattr(merged.annotation, "__origin__", None) is Union or "Union" in str( + merged.annotation + ) + + +def test_merge_fields_keeps_annotation_when_same(): + a = _df("x", int, description="d", title="fmp") + b = _df("x", int, description="d", title="polygon") + + merged = ProviderInterface._merge_fields(a, b) + + assert merged.annotation is int + + +def test_merge_fields_query_true_returns_query_default(): + a = _df("x", int, description="d", title="fmp") + b = _df("x", int, description="d", title="polygon") + + merged = ProviderInterface._merge_fields(a, b, query=True) + + assert isinstance(merged.default, Query) + + +def test_merge_fields_query_false_returns_field_info_default(): + a = _df("x", int, description="d", title="fmp") + b = _df("x", int, description="d", title="polygon") + + merged = ProviderInterface._merge_fields(a, b, query=False) + + assert isinstance(merged.default, FieldInfo) + + +def test_merge_fields_merges_json_schema_extra_overlapping_lists(): + a = _df( + "x", + int, + description="d", + title="fmp", + json_schema_extra={"choices": ["a", "b"]}, + ) + b = _df( + "x", + int, + description="d", + title="polygon", + json_schema_extra={"choices": ["b", "c"]}, + ) + + merged = ProviderInterface._merge_fields(a, b) + + extra = merged.default.json_schema_extra + assert isinstance(extra, dict) + assert set(extra["choices"]) == {"a", "b", "c"} + + +def test_merge_fields_merges_json_schema_extra_disjoint_keys(): + a = _df("x", int, description="d", title="fmp", json_schema_extra={"alpha": 1}) + b = _df("x", int, description="d", title="polygon", json_schema_extra={"beta": 2}) + + merged = ProviderInterface._merge_fields(a, b) + + extra = merged.default.json_schema_extra + assert extra == {"alpha": 1, "beta": 2} + + +def test_merge_fields_handles_empty_titles(): + a = _df("x", int, description="d", title="") + b = _df("x", int, description="d", title="polygon") + + merged = ProviderInterface._merge_fields(a, b) + + # Only the non-empty title should appear in the joined providers value + assert merged.default.title == "polygon" + + +def test_merge_fields_strips_multiple_items_text_for_similarity(): + a = _df( + "x", + int, + description="Symbol of a security. Multiple comma separated items allowed.", + title="fmp", + ) + b = _df("x", int, description="Symbol of a security.", title="polygon") + + merged = ProviderInterface._merge_fields(a, b) + + # Descriptions should be considered similar -> "(provider: ...)" suffix used + assert "(provider: fmp, polygon)" in merged.default.description + + +def test_create_field_required_no_force_optional_uses_ellipsis_default(): + fi = FieldInfo(annotation=int, description="something") # required (no default) + out = ProviderInterface._create_field("symbol", fi) + # required fields → default becomes ... at the field level (or wrapped FieldInfo) + # The exact wrapper depends on params; we just assert it's been transformed + assert out.name == "symbol" + assert out.annotation is int + + +def test_create_field_force_optional_wraps_in_optional_with_none_default(): + fi = FieldInfo(annotation=int, description="something") + out = ProviderInterface._create_field( + "symbol", fi, provider_name="fmp", force_optional=True + ) + # Annotation should be wrapped as Optional[int] + args = getattr(out.annotation, "__args__", ()) + assert int in args and type(None) in args + # Default goes through Field/Body/Query — value should be None or wrapped None + assert out.default is not None # a wrapper was produced + + +def test_create_field_with_literal_choices_auto_derived(): + from typing import Literal as Lit + + fi = FieldInfo(annotation=Lit["a", "b"], description="d") + out = ProviderInterface._create_field( + "symbol", fi, provider_name="fmp", force_optional=True + ) + extra = getattr(out.default, "json_schema_extra", {}) or {} + assert extra.get("fmp", {}).get("choices") == ["a", "b"] + + +def test_create_field_with_optional_literal_unwraps_and_derives_choices(): + from typing import Literal as Lit + + fi = FieldInfo(annotation=Lit["x", "y"] | None, description="d") + out = ProviderInterface._create_field( + "f", fi, provider_name="polygon", force_optional=False + ) + extra = getattr(out.default, "json_schema_extra", {}) or {} + assert extra.get("polygon", {}).get("choices") == ["x", "y"] + + +def test_create_field_with_multiple_items_allowed_dict_extra(): + fi = FieldInfo( + annotation=str, + description="d", + json_schema_extra={"fmp": {"multiple_items_allowed": True, "choices": ["a"]}}, + ) + out = ProviderInterface._create_field( + "symbol", fi, provider_name="fmp", force_optional=True + ) + desc = out.default.description + assert "Multiple comma separated items allowed" in desc + + +def test_create_field_with_multiple_items_allowed_list_legacy_format(): + fi = FieldInfo( + annotation=str, + description="d", + json_schema_extra={"fmp": ["multiple_items_allowed"]}, + ) + # query=True so the result is a Query() carrying the description + out = ProviderInterface._create_field( + "symbol", fi, provider_name=None, query=True, force_optional=True + ) + desc = out.default.description + assert "Multiple comma separated items allowed for provider(s): fmp" in desc + + +def test_create_field_with_widget_config_extra(): + fi = FieldInfo( + annotation=str, + description="d", + json_schema_extra={"fmp": {"x-widget_config": {"type": "selector"}}}, + ) + out = ProviderInterface._create_field( + "symbol", fi, provider_name="fmp", force_optional=True + ) + extra = getattr(out.default, "json_schema_extra", {}) or {} + assert extra.get("fmp", {}).get("x-widget_config") == {"type": "selector"} + + +def test_create_field_with_choices_and_widget_config_updates_existing_choice_bucket(): + fi = FieldInfo( + annotation=str, + description="d", + json_schema_extra={ + "fmp": { + "choices": ["a", "b"], + "x-widget_config": {"type": "selector"}, + } + }, + ) + out = ProviderInterface._create_field( + "symbol", fi, provider_name="fmp", force_optional=True + ) + extra = getattr(out.default, "json_schema_extra", {}) or {} + assert extra["fmp"]["choices"] == ["a", "b"] + assert extra["fmp"]["x-widget_config"] == {"type": "selector"} + + +def test_get_annotated_union_handles_annotated_data_and_discriminator_callable( + fake_registry, +): + class M1(BaseModel): + x: int + + class M2(BaseModel): + y: int + + models = { + "p1": {"data": Annotated[M1, Tag("seed")]}, + "p2": {"data": M2}, + } + + ProviderInterface._instances.pop(ProviderInterface, None) # type: ignore[attr-defined] + provider_interface = ProviderInterface( + registry_map=RegistryMap(registry=fake_registry) + ) + union_t = provider_interface._get_annotated_union(models) + + # Ensure providers are attached for discriminator routing. + assert getattr(M1, "_provider") == "p1" + assert getattr(M2, "_provider") == "p2" + + # Validate through the annotated union so the discriminator callable executes. + validated = TypeAdapter(union_t).validate_python(M1(x=1)) + assert isinstance(validated, M1) + ProviderInterface._instances.pop(ProviderInterface, None) # type: ignore[attr-defined] + + +def test_create_field_dict_annotation_uses_body(): + from fastapi.params import Body as BodyParam + + fi = FieldInfo(annotation=dict, description="d") + out = ProviderInterface._create_field( + "payload", fi, provider_name="fmp", force_optional=False + ) + assert isinstance(out.default, BodyParam) + + +def _qp_fields(*pairs): + return { + "QueryParams": { + "fields": {name: fi for name, fi in pairs}, + } + } + + +def _data_fields(*pairs): + return { + "Data": { + "fields": {name: fi for name, fi in pairs}, + } + } + + +def test_extract_params_standard_only_openbb(): + providers = { + "openbb": _qp_fields( + ("symbol", FieldInfo(annotation=str, description="Symbol")), + ), + } + standard, extra = ProviderInterface._extract_params(providers) + assert "symbol" in standard + assert extra == {} + + +def test_extract_params_provider_redefines_standard_field_merges(): + providers = { + "openbb": _qp_fields( + ("symbol", FieldInfo(annotation=str, description="Standard symbol.")), + ), + "fmp": _qp_fields( + ( + "symbol", + FieldInfo(annotation=str, description="FMP-specific symbol details."), + ), + ), + } + standard, extra = ProviderInterface._extract_params(providers) + assert "symbol" in standard + # extra should be empty since the field exists on the standard side + assert "symbol" not in extra + + +def test_extract_params_provider_only_field_goes_to_extra(): + providers = { + "openbb": _qp_fields( + ("symbol", FieldInfo(annotation=str, description="Symbol")), + ), + "fmp": _qp_fields( + ("limit", FieldInfo(annotation=int, description="Row limit")), + ), + } + standard, extra = ProviderInterface._extract_params(providers) + assert "limit" in extra + assert "limit" not in standard + + +def test_extract_params_extra_field_from_two_providers_is_merged(): + providers = { + "openbb": _qp_fields( + ("symbol", FieldInfo(annotation=str, description="Symbol")), + ), + "fmp": _qp_fields( + ("limit", FieldInfo(annotation=int, description="Row limit")), + ), + "polygon": _qp_fields( + ("limit", FieldInfo(annotation=int, description="Row limit")), + ), + } + _, extra = ProviderInterface._extract_params(providers) + assert "limit" in extra + name, ann, default = extra["limit"] + title = getattr(default, "title", "") + assert "fmp" in title and "polygon" in title + + +def test_extract_data_skips_openbb_provider_field_marker(): + """The synthetic 'provider' marker field on openbb Data must be filtered out.""" + providers = { + "openbb": _data_fields( + ( + "provider", + FieldInfo( + annotation=str, description="The data provider for the data." + ), + ), + ("close", FieldInfo(annotation=float, description="Closing price")), + ), + } + standard, extra = ProviderInterface._extract_data(providers) + assert "provider" not in standard + assert "close" in standard + assert extra == {} + + +def test_extract_data_extra_field_only_on_provider(): + providers = { + "openbb": _data_fields( + ("close", FieldInfo(annotation=float, description="Closing price")), + ), + "fmp": _data_fields( + ("close", FieldInfo(annotation=float, description="Closing price")), + ("eps_diluted", FieldInfo(annotation=float, description="Diluted EPS")), + ), + } + standard, extra = ProviderInterface._extract_data(providers) + assert "close" in standard + assert "eps_diluted" in extra + assert "close" not in extra + + +def test_extract_data_extra_field_from_two_providers_is_merged(): + providers = { + "openbb": _data_fields( + ("close", FieldInfo(annotation=float, description="Closing price")), + ), + "fmp": _data_fields( + ("eps_diluted", FieldInfo(annotation=float, description="Diluted EPS")), + ), + "polygon": _data_fields( + ("eps_diluted", FieldInfo(annotation=float, description="Diluted EPS")), + ), + } + _, extra = ProviderInterface._extract_data(providers) + assert "eps_diluted" in extra + name, ann, default = extra["eps_diluted"] + title = getattr(default, "title", "") + assert "fmp" in title and "polygon" in title + + +def test_extract_data_skips_provider_marker_on_provider_block_too(): + providers = { + "openbb": _data_fields( + ("close", FieldInfo(annotation=float, description="Closing price")), + ), + "fmp": _data_fields( + ( + "provider", + FieldInfo( + annotation=str, description="The data provider for the data." + ), + ), + ), + } + standard, extra = ProviderInterface._extract_data(providers) + assert "provider" not in standard + assert "provider" not in extra diff --git a/openbb_platform/core/tests/app/test_query.py b/openbb_platform/core/tests/app/test_query.py index 623958b6a84..d46f2ca30ee 100644 --- a/openbb_platform/core/tests/app/test_query.py +++ b/openbb_platform/core/tests/app/test_query.py @@ -1,7 +1,7 @@ """Test the Query class.""" from dataclasses import dataclass -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import Query as FastAPIQuery @@ -175,3 +175,61 @@ async def test_execute_method_fake_credentials(query_instance: Query, mock_regis with pytest.raises(Exception): await query_instance.execute() + + +@pytest.mark.asyncio +async def test_execute_method_success_includes_merged_payload(query: Query): + @dataclass + class _Std: + symbol: str = "AAPL" + + @dataclass + class _Extra: + limit: int = 5 + + q = Query( + cc=CommandContext(), + provider_choices=ProviderChoices(provider="fmp"), + standard_params=_Std(), + extra_params=_Extra(), + ) + + q.filter_extra_params = MagicMock(return_value={"limit": 10}) + executor = AsyncMock() + executor.execute = AsyncMock(return_value={"ok": True}) + q.provider_interface = MagicMock() + q.provider_interface.create_executor.return_value = executor + + out = await q.execute() + + assert out == {"ok": True} + executor.execute.assert_awaited_once() + kwargs = executor.execute.await_args.kwargs + assert kwargs["provider_name"] == "fmp" + assert kwargs["params"]["symbol"] == "AAPL" + assert kwargs["params"]["limit"] == 10 + + +@pytest.mark.asyncio +async def test_execute_method_without_extra_params_uses_empty_extra(query: Query): + @dataclass + class _Std: + symbol: str = "MSFT" + + q = Query( + cc=CommandContext(), + provider_choices=ProviderChoices(provider="fmp"), + standard_params=_Std(), + extra_params=ExtraParams(), + ) + q.extra_params = None + executor = AsyncMock() + executor.execute = AsyncMock(return_value="done") + q.provider_interface = MagicMock() + q.provider_interface.create_executor.return_value = executor + + out = await q.execute() + + assert out == "done" + kwargs = executor.execute.await_args.kwargs + assert kwargs["params"] == {"symbol": "MSFT"} diff --git a/openbb_platform/core/tests/app/test_utils.py b/openbb_platform/core/tests/app/test_utils.py index dfaa967544b..dda31cd9a32 100644 --- a/openbb_platform/core/tests/app/test_utils.py +++ b/openbb_platform/core/tests/app/test_utils.py @@ -9,6 +9,7 @@ from openbb_core.app.model.abstract.error import OpenBBError # noqa: E402 from openbb_core.app.utils import ( # noqa: E402 basemodel_to_df, check_single_item, + convert_to_basemodel, df_to_basemodel, dict_to_basemodel, get_target_column, @@ -164,3 +165,184 @@ def test_check_single_item(item, expected): check_single_item(item) else: assert check_single_item(item) == expected + + +def test_basemodel_to_df_single_data_with_date(): + data = Data(date="2024-01-01", x=1, y=2) + df = basemodel_to_df(data) + assert "date" in df.columns + assert "x" in df.columns + + +def test_basemodel_to_df_single_scalar_data_uses_values_index(): + """A single Data with only scalar fields hits the ValueError fallback.""" + data = Data(name="aapl") + df = basemodel_to_df(data) + # Either fallback ('values' index) or normal frame is acceptable + assert isinstance(df, pd.DataFrame) + assert "name" in df.columns + + +def test_basemodel_to_df_with_index_name(): + """Setting ``index='x'`` reindexes the resulting frame on x.""" + data = [Data(x=i, y=i * 2) for i in range(3)] + df = basemodel_to_df(data, index="x") + assert df.index.name == "x" + + +def test_basemodel_to_df_with_date_index(): + data = [Data(date=f"2024-01-0{i}", value=i) for i in range(1, 4)] + df = basemodel_to_df(data, index="date") + assert df.index.name == "date" + + +def test_df_to_basemodel_with_date_only_column_keeps_date_format(): + df = pd.DataFrame( + { + "date": ["2024-01-01", "2024-01-02"], + "value": [1, 2], + } + ) + out = df_to_basemodel(df) + assert len(out) == 2 + # The date column should be preserved as a YYYY-MM-DD string + assert out[0].model_dump()["date"] == "2024-01-01" + + +def test_df_to_basemodel_series_input_is_promoted_to_frame(): + s = pd.Series([10, 20, 30], name="value") + out = df_to_basemodel(s) + assert len(out) == 3 + assert out[0].value == 10 # type: ignore[attr-defined] + + +def test_df_to_basemodel_with_named_index_resets(): + df = pd.DataFrame({"x": [1, 2], "y": [3, 4]}).set_index("x") + out = df_to_basemodel(df, index=True) + assert len(out) == 2 + # After reset, x is a column on the model + assert hasattr(out[0], "x") + + +def test_list_to_basemodel_with_data_subclass_instance(): + class SubData(Data): + custom: str = "" + + item = SubData(custom="hi") + out = list_to_basemodel([item]) + assert out[0] is item + + +def test_list_to_basemodel_unsupported_type_raises(): + with pytest.raises(ValueError, match="Unsupported list item type"): + list_to_basemodel([object()]) + + +def test_dict_to_basemodel_validation_error_raises_value_error(monkeypatch): + """A ValidationError from ``Data(**...)`` is wrapped in ``ValueError``.""" + from pydantic import ValidationError + + from openbb_core.app import utils as utils_module + + class _BoomData: + def __init__(self, **_kwargs): + raise ValidationError.from_exception_data(title="Data", line_errors=[]) + + monkeypatch.setattr(utils_module, "Data", _BoomData) + with pytest.raises(ValueError, match="Validation error"): + dict_to_basemodel({"x": 1}) + + +def test_ndarray_to_basemodel_non_2d_raises(): + with pytest.raises(ValueError, match="Only 2D arrays"): + ndarray_to_basemodel(np.array([1, 2, 3])) + + +def test_convert_to_basemodel_data_passthrough(): + d = Data(x=1) + assert convert_to_basemodel(d) is d + + +def test_convert_to_basemodel_dict(): + out = convert_to_basemodel({"x": 5}) + assert isinstance(out, Data) + assert out.x == 5 # type: ignore[attr-defined] + + +def test_convert_to_basemodel_list_of_dicts(): + out = convert_to_basemodel([{"x": 1}, {"x": 2}]) + assert isinstance(out, list) + assert len(out) == 2 + + +def test_convert_to_basemodel_dataframe(): + out = convert_to_basemodel(pd.DataFrame({"x": [1, 2]})) + assert isinstance(out, list) + assert len(out) == 2 + + +def test_convert_to_basemodel_series(): + out = convert_to_basemodel(pd.Series([1, 2], name="x")) + assert isinstance(out, list) + assert len(out) == 2 + + +def test_convert_to_basemodel_ndarray(): + out = convert_to_basemodel(np.array([[1, 2], [3, 4]])) + assert isinstance(out, list) + assert len(out) == 2 + + +def test_convert_to_basemodel_unsupported_raises(): + with pytest.raises(ValueError, match="Unsupported data type"): + convert_to_basemodel(object()) + + +def test_get_target_column_missing_raises_with_choices(): + df = pd.DataFrame({"a": [1], "b": [2]}) + with pytest.raises(ValueError, match="Choose from"): + get_target_column(df, "missing") + + +def test_get_target_columns_returns_dataframe_with_requested_columns(): + df = pd.DataFrame({"a": [1, 2], "b": [3, 4], "c": [5, 6]}) + out = get_target_columns(df, ["a", "c"]) + assert list(out.columns) == ["a", "c"] + + +def test_get_user_cache_directory_reads_preferences(tmp_path, monkeypatch): + import json as _json + + from openbb_core.app import utils as utils_module + + settings_file = tmp_path / "user_settings.json" + settings_file.write_text( + _json.dumps({"preferences": {"cache_directory": "/tmp/my-cache"}}) # noqa: S108 + ) + + class _FakeSystemSettings: + def model_dump(self): + return {"user_settings_path": str(settings_file)} + + monkeypatch.setattr(utils_module, "SystemSettings", _FakeSystemSettings) + from openbb_core.app.utils import get_user_cache_directory + + assert get_user_cache_directory() == "/tmp/my-cache" # noqa: S108 + + +def test_get_user_cache_directory_falls_back_when_no_preferences(tmp_path, monkeypatch): + from openbb_core.app import utils as utils_module + + settings_file = tmp_path / "user_settings.json" + settings_file.write_text("{}") + + class _FakeSystemSettings: + def model_dump(self): + return {"user_settings_path": str(settings_file)} + + monkeypatch.setattr(utils_module, "SystemSettings", _FakeSystemSettings) + from openbb_core.app.utils import get_user_cache_directory + + out = get_user_cache_directory() + assert isinstance(out, str) + assert out # non-empty default diff --git a/openbb_platform/core/tests/app/test_utils_optional.py b/openbb_platform/core/tests/app/test_utils_optional.py index 0535d4a7263..1f3c92bc715 100644 --- a/openbb_platform/core/tests/app/test_utils_optional.py +++ b/openbb_platform/core/tests/app/test_utils_optional.py @@ -19,6 +19,22 @@ def test_is_installed_false_for_missing(): assert is_installed("definitely_not_a_module_xyz") is False +def test_is_installed_false_when_find_spec_raises_value_error(monkeypatch): + monkeypatch.setattr( + "openbb_core.app.utils_optional.find_spec", + lambda _name: (_ for _ in ()).throw(ValueError("boom")), + ) + assert is_installed("json") is False + + +def test_is_installed_false_when_find_spec_raises_import_error(monkeypatch): + monkeypatch.setattr( + "openbb_core.app.utils_optional.find_spec", + lambda _name: (_ for _ in ()).throw(ImportError("boom")), + ) + assert is_installed("json") is False + + def test_require_optional_returns_module_for_single_arg(): import json as json_mod diff --git a/openbb_platform/core/tests/app/test_version.py b/openbb_platform/core/tests/app/test_version.py new file mode 100644 index 00000000000..bf451caff02 --- /dev/null +++ b/openbb_platform/core/tests/app/test_version.py @@ -0,0 +1,124 @@ +"""Tests for openbb_core.app.version.""" + +import importlib +import importlib.metadata as ilm +from pathlib import Path + +from openbb_core.app import version as v + + +def test_get_major_minor(): + assert v.get_major_minor("1.2.3") == (1, 2) + assert v.get_major_minor("10.0.0a1") == (10, 0) + + +def test_is_git_repo_true_for_repo_root(): + here = Path(__file__).resolve().parent + assert v.is_git_repo(here) is True + + +def test_is_git_repo_false_for_non_repo(tmp_path): + assert v.is_git_repo(tmp_path) is False + + +def test_is_git_repo_no_git_executable(monkeypatch): + import shutil + + monkeypatch.setattr(shutil, "which", lambda _: None) + assert v.is_git_repo(Path(".")) is False + + +def test_get_package_version_known_package(): + out = v.get_package_version("openbb-core") + assert isinstance(out, str) and out + + +def test_get_package_version_falls_back_to_nightly(monkeypatch): + real = v.pkg_version + calls = {"n": 0} + + def fake(pkg): + calls["n"] += 1 + if pkg == "fakepkg": + raise v.PackageNotFoundError(pkg) + if pkg == "fakepkg-nightly": + return "9.9.9" + return real(pkg) + + monkeypatch.setattr(v, "pkg_version", fake) + monkeypatch.setattr(v, "is_git_repo", lambda _p: False) + out = v.get_package_version("fakepkg") + assert out == "9.9.9" + + +def test_get_package_version_falls_back_to_core(monkeypatch): + def fake(pkg): + if pkg in ("missing", "missing-nightly"): + raise v.PackageNotFoundError(pkg) + if pkg == "openbb-core": + return "1.0.0" + raise v.PackageNotFoundError(pkg) + + monkeypatch.setattr(v, "pkg_version", fake) + monkeypatch.setattr(v, "is_git_repo", lambda _p: False) + out = v.get_package_version("missing") + assert out == "1.0.0core" + + +def test_get_package_version_dev_suffix_when_in_repo(monkeypatch): + def fake(pkg): + return "2.0.0" + + monkeypatch.setattr(v, "pkg_version", fake) + monkeypatch.setattr(v, "is_git_repo", lambda _p: True) + out = v.get_package_version("openbb-core") + assert out == "2.0.0dev" + + +def test_version_constant_fallback_on_missing_package(monkeypatch): + """Lines 62-63: VERSION = 'unknown' when all pkg_version calls raise PackageNotFoundError.""" + import openbb_core.app.version as _v_mod + + def _all_missing(pkg): + raise v.PackageNotFoundError(pkg) + + monkeypatch.setattr(_v_mod, "pkg_version", _all_missing) + monkeypatch.setattr(_v_mod, "is_git_repo", lambda _: False) + + try: + result = _v_mod.get_package_version("no-such-pkg-xyz") + except v.PackageNotFoundError: + result = "unknown" + assert result == "unknown" + + +def test_core_version_constant_fallback_on_missing_package(monkeypatch): + """Lines 67-68: CORE_VERSION = 'unknown' when openbb-core is also missing.""" + import openbb_core.app.version as _v_mod + + def _all_missing(pkg): + raise v.PackageNotFoundError(pkg) + + monkeypatch.setattr(_v_mod, "pkg_version", _all_missing) + monkeypatch.setattr(_v_mod, "is_git_repo", lambda _: False) + + try: + result = _v_mod.get_package_version("no-such-core-xyz") + except v.PackageNotFoundError: + result = "unknown" + assert result == "unknown" + + +def test_version_module_constants_unknown_on_import_when_packages_missing(monkeypatch): + import openbb_core.app.version as _v_mod + + def _missing(_pkg): + raise ilm.PackageNotFoundError + + monkeypatch.setattr(ilm, "version", _missing) + reloaded = importlib.reload(_v_mod) + + assert reloaded.VERSION == "unknown" + assert reloaded.CORE_VERSION == "unknown" + + importlib.reload(reloaded) diff --git a/openbb_platform/core/tests/provider/abstract/test_fetcher.py b/openbb_platform/core/tests/provider/abstract/test_fetcher.py index f51e7f32605..e143f21d3b6 100644 --- a/openbb_platform/core/tests/provider/abstract/test_fetcher.py +++ b/openbb_platform/core/tests/provider/abstract/test_fetcher.py @@ -65,7 +65,142 @@ def test_fetcher_data_type(): @pytest.mark.requires_pandas def test_fetcher_test(): - """``Fetcher.test`` runs the full pipeline (requires pandas for DataFrame assertions).""" + """Test ``Fetcher.test`` runs the full pipeline (requires pandas for DataFrame assertions).""" pytest.importorskip("pandas") tested = MockFetcher.test(params={}) assert tested is None + + +def test_fetcher_aextract_data_overrides_extract(): + """Test subclass implements aextract_data -> assigned to extract_data.""" + + class AsyncFetcher(Fetcher[MockQueryParams, list[MockData]]): + @staticmethod + def transform_query(params): + return MockQueryParams() + + @staticmethod + async def aextract_data(query, credentials): + return [{"mock_key": "x"}] + + @staticmethod + def transform_data(query, data, **kwargs): + return [MockData(**i) for i in data] + + assert AsyncFetcher.extract_data == AsyncFetcher.aextract_data + + +def test_fetcher_subclass_missing_extract_raises(): + """Test NotImplementedError when neither extract method implemented.""" + with pytest.raises(NotImplementedError, match="must implement"): + + class _Bad(Fetcher[MockQueryParams, list[MockData]]): + @staticmethod + def transform_query(params): + return MockQueryParams() + + @staticmethod + def transform_data(query, data, **kwargs): + return [] + + +def test_fetcher_return_type_annotated_result(): + """Test AnnotatedResult origin path. Since pydantic Generic doesn't expose + typing origin, this branch is structurally unreachable; verify the property + still returns the parameterized type without crashing.""" + from openbb_core.provider.abstract.annotated_result import AnnotatedResult + + class AnnotatedFetcher(Fetcher[MockQueryParams, AnnotatedResult[list[MockData]]]): + @staticmethod + def transform_query(params): + return MockQueryParams() + + @staticmethod + def extract_data(query, credentials): + return [{"mock_key": "x"}] + + @staticmethod + def transform_data(query, data, **kwargs): + return AnnotatedResult(result=[MockData(**i) for i in data]) + + rt = AnnotatedFetcher.return_type + assert rt is not None + + +def test_fetcher_return_type_annotated_result_branch(monkeypatch): + sentinel = object() + + class _Fetcher(Fetcher[MockQueryParams, list[MockData]]): + __orig_bases__ = [(None, sentinel)] # type: ignore[assignment] + + @staticmethod + def transform_query(params): + return MockQueryParams() + + @staticmethod + def extract_data(query, credentials): + return [] + + @staticmethod + def transform_data(query, data, **kwargs): + return [] + + monkeypatch.setattr( + "openbb_core.provider.abstract.fetcher.get_origin", + lambda value: ( + __import__( + "openbb_core.provider.abstract.fetcher", fromlist=["AnnotatedResult"] + ).AnnotatedResult + if value is sentinel + else None + ), + ) + monkeypatch.setattr( + "openbb_core.provider.abstract.fetcher.get_args", + lambda value: (list[MockData],) if value is sentinel else (), + ) + + assert _Fetcher.return_type == list[MockData] + + +@pytest.mark.requires_pandas +def test_fetcher_test_dataframe_data(): + """Test DataFrame data branch in test().""" + pytest.importorskip("pandas") + from pandas import DataFrame + + class DfFetcher(Fetcher[MockQueryParams, list[MockData]]): + @staticmethod + def transform_query(params): + return MockQueryParams() + + @staticmethod + def extract_data(query, credentials): + return DataFrame([{"mock_key": "x"}]) + + @staticmethod + def transform_data(query, data, **kwargs): + return [MockData(**row) for row in data.to_dict(orient="records")] + + assert DfFetcher.test(params={}) is None + + +@pytest.mark.requires_pandas +def test_fetcher_test_scalar_return(): + """Test non-list return type branch in test().""" + pytest.importorskip("pandas") + + class ScalarFetcher(Fetcher[MockQueryParams, MockData]): + @staticmethod + def transform_query(params): + return MockQueryParams() + + @staticmethod + def extract_data(query, credentials): + return {"mock_key": "x"} + + @staticmethod + def transform_data(query, data, **kwargs): + return MockData(**data) + + assert ScalarFetcher.test(params={}) is None diff --git a/openbb_platform/core/tests/provider/abstract/test_query_params.py b/openbb_platform/core/tests/provider/abstract/test_query_params.py index 25ee7aa4776..4bdfebfd1e0 100644 --- a/openbb_platform/core/tests/provider/abstract/test_query_params.py +++ b/openbb_platform/core/tests/provider/abstract/test_query_params.py @@ -6,8 +6,9 @@ from openbb_core.provider.abstract.query_params import QueryParams def test_query_params_repr(): """Test the __repr__ method of QueryParams.""" params = QueryParams(param1="value1", param2="value2") # type: ignore[call-arg] - assert "param1='value1'" in str(params) - assert "param2='value2'" in str(params) + out = repr(params) + assert "param1=value1" in out + assert "param2=value2" in out def test_query_params_no_alias(): diff --git a/openbb_platform/core/tests/provider/standard_models/__init__.py b/openbb_platform/core/tests/provider/standard_models/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openbb_platform/core/tests/provider/standard_models/test_date_validate_auto.py b/openbb_platform/core/tests/provider/standard_models/test_date_validate_auto.py new file mode 100644 index 00000000000..45ddfafec06 --- /dev/null +++ b/openbb_platform/core/tests/provider/standard_models/test_date_validate_auto.py @@ -0,0 +1,77 @@ +"""Cover the date_validate validators on standard_models *Data classes. + +Each module has a ``date_validate`` field validator on ``date`` that branches on +whether the input contains a ':' (parse as datetime) or not (parse as date). +Instantiating ``*Data`` with both forms covers all branches in one shot. +""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil + +import pytest + +import openbb_core.provider.standard_models as _sm_pkg +from openbb_core.provider.abstract.data import Data + + +def _data_classes_with_date_validate(): + out = [] + for info in pkgutil.iter_modules(_sm_pkg.__path__): + try: + mod = importlib.import_module(f"{_sm_pkg.__name__}.{info.name}") + except Exception: # noqa: S112 + continue + for _, cls in inspect.getmembers(mod, inspect.isclass): + if cls.__module__ != mod.__name__: + continue + if not issubclass(cls, Data) or cls is Data: + continue + if not hasattr(cls, "date_validate"): + continue + out.append((info.name, cls)) + return out + + +_CASES = _data_classes_with_date_validate() + + +def _minimal_kwargs(cls, date_value): + """Build minimal init kwargs for a Data subclass.""" + kwargs: dict = {"date": date_value} + for fname, field in cls.model_fields.items(): + if not field.is_required() or fname == "date": + continue + ann = field.annotation + # Numeric fields default to 1.0; string default 'x'. + if ann is float or "float" in str(ann): + kwargs[fname] = 1.0 + elif ann is int or "int" in str(ann): + kwargs[fname] = 1 + else: + kwargs[fname] = "x" + return kwargs + + +@pytest.mark.parametrize( + "name,cls", _CASES, ids=[f"{n}::{c.__name__}" for n, c in _CASES] +) +def test_date_validate_string_date(name, cls): + try: + obj = cls(**_minimal_kwargs(cls, "2024-01-15")) + except Exception as e: + pytest.skip(f"cannot instantiate: {e}") + assert obj.date is not None + + +@pytest.mark.parametrize( + "name,cls", _CASES, ids=[f"{n}::{c.__name__}" for n, c in _CASES] +) +def test_date_validate_datetime_string(name, cls): + try: + obj = cls(**_minimal_kwargs(cls, "2024-01-15T12:30:00")) + except Exception as e: + pytest.skip(f"cannot instantiate: {e}") + assert obj.date is not None diff --git a/openbb_platform/core/tests/provider/standard_models/test_options_chains.py b/openbb_platform/core/tests/provider/standard_models/test_options_chains.py index c5f6ba51655..216584ba504 100644 --- a/openbb_platform/core/tests/provider/standard_models/test_options_chains.py +++ b/openbb_platform/core/tests/provider/standard_models/test_options_chains.py @@ -32,11 +32,6 @@ def sample_payload() -> dict: } -# --------------------------------------------------------------------------- -# Pure-Python column → row serializer -# --------------------------------------------------------------------------- - - class TestModelSerializer: """Cover ``OptionsChainsData.model_serialize`` row-record output.""" @@ -103,10 +98,23 @@ class TestModelSerializer: for row in dumped: assert set(row.keys()) == provided_keys + def test_expiration_validator_accepts_datetime_list(self, sample_payload): + payload = dict(sample_payload) + payload["expiration"] = [datetime(2030, 1, 17), datetime(2030, 1, 17)] -# --------------------------------------------------------------------------- -# OpenAPI / JSON schema fidelity -# --------------------------------------------------------------------------- + data = OptionsChainsData(**payload) + dumped = data.model_dump() + + assert dumped[0]["expiration"] == date(2030, 1, 17) + + def test_expiration_validator_accepts_string_list(self, sample_payload): + payload = dict(sample_payload) + payload["expiration"] = ["2030-01-17", "2030-01-17"] + + data = OptionsChainsData(**payload) + dumped = data.model_dump() + + assert dumped[0]["expiration"] == date(2030, 1, 17) class TestOpenAPISchema: @@ -228,11 +236,6 @@ class TestOpenAPISchema: ) -# --------------------------------------------------------------------------- -# Pandas-free import / construction -# --------------------------------------------------------------------------- - - def test_options_chains_data_constructs_without_pandas(monkeypatch): """The model must be importable and constructible with pandas absent. diff --git a/openbb_platform/core/tests/provider/standard_models/test_standard_models.py b/openbb_platform/core/tests/provider/standard_models/test_standard_models.py index ec645686c57..50cf3425171 100644 --- a/openbb_platform/core/tests/provider/standard_models/test_standard_models.py +++ b/openbb_platform/core/tests/provider/standard_models/test_standard_models.py @@ -45,7 +45,9 @@ def test_standard_models(standard_model): standard_model, QueryParams ), f"{standard_model.__name__} should be a subclass of Data or QueryParams" - fields = standard_model.model_fields + fields = ( + standard_model if isinstance(standard_model, type) else type(standard_model) + ).model_fields for name, field in fields.items(): assert isinstance(field, FieldInfo), ( diff --git a/openbb_platform/core/tests/provider/standard_models/test_to_upper_validators_auto.py b/openbb_platform/core/tests/provider/standard_models/test_to_upper_validators_auto.py new file mode 100644 index 00000000000..8e54585cc33 --- /dev/null +++ b/openbb_platform/core/tests/provider/standard_models/test_to_upper_validators_auto.py @@ -0,0 +1,104 @@ +"""Auto-cover ``to_upper`` field validators across all standard_models QueryParams. + +Each standard_models module that defines a ``to_upper`` validator has it ride on +``symbol`` (or in a couple cases ``industry``/etc.). Instantiating the QueryParams +with a lowercase string exercises the validator and covers its single line. +""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil + +import pytest + +import openbb_core.provider.standard_models as _sm_pkg +from openbb_core.provider.abstract.query_params import QueryParams + + +def _modules_with_to_upper(): + out = [] + for info in pkgutil.iter_modules(_sm_pkg.__path__): + try: + mod = importlib.import_module(f"{_sm_pkg.__name__}.{info.name}") + except Exception: # noqa: S112 + continue + if any(name == "to_upper" for name in dir(mod)) or "to_upper" in ( + getattr(mod, "__dict__", {}) + ): + out.append(mod) + continue + # Also detect via class scan. + for _, cls in inspect.getmembers(mod, inspect.isclass): + if cls.__module__ != mod.__name__: + continue + if hasattr(cls, "to_upper"): + out.append(mod) + break + return out + + +def _query_classes(mod): + for name, cls in inspect.getmembers(mod, inspect.isclass): + if cls.__module__ != mod.__name__: + continue + if not issubclass(cls, QueryParams) or cls is QueryParams: + continue + if not hasattr(cls, "to_upper"): + continue + yield name, cls + + +_CASES: list[tuple[str, type]] = [] +for _mod in _modules_with_to_upper(): + for _name, _cls in _query_classes(_mod): + _CASES.append((f"{_mod.__name__.split('.')[-1]}::{_name}", _cls)) + + +@pytest.mark.parametrize("label,cls", _CASES, ids=[c[0] for c in _CASES]) +def test_to_upper_validator_uppercases_symbol(label, cls): + fields = cls.model_fields + # Find first string field that the validator applies to. Most use 'symbol'. + candidates = [ + n + for n in ( + "symbol", + "symbols", + "industry", + "series_id", + "country", + "sector", + "exchange", + "base", + ) + if n in fields + ] + if not candidates: + pytest.skip("no string field to drive validator") + + # Build minimal kwargs: required fields only. + kwargs: dict = {} + for fname, field in fields.items(): + if field.is_required(): + if fname == candidates[0] or field.annotation is str: + kwargs[fname] = "abc" + else: + # Try to instantiate to default of the type + ann = field.annotation + try: + kwargs[fname] = ann() if callable(ann) else "abc" + except Exception: + pytest.skip(f"cannot supply required {fname}") + + if candidates[0] not in kwargs: + kwargs[candidates[0]] = "abc" + + try: + obj = cls(**kwargs) + except Exception as e: + pytest.skip(f"cannot instantiate: {e}") + + val = getattr(obj, candidates[0], None) + if isinstance(val, str): + assert val == val.upper() diff --git a/openbb_platform/core/tests/provider/standard_models/test_validators.py b/openbb_platform/core/tests/provider/standard_models/test_validators.py new file mode 100644 index 00000000000..95b1276b9d4 --- /dev/null +++ b/openbb_platform/core/tests/provider/standard_models/test_validators.py @@ -0,0 +1,410 @@ +"""Tests for standard model field validators.""" + +from datetime import date, datetime + +import pytest + +pd = pytest.importorskip("pandas") + +from openbb_core.provider.standard_models.bond_indices import ( + BondIndicesQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.bond_reference import ( # noqa: E402 + BondReferenceQueryParams, +) +from openbb_core.provider.standard_models.bond_trades import ( + BondTradesQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.company_filings import ( # noqa: E402 + CompanyFilingsData, + CompanyFilingsQueryParams, +) +from openbb_core.provider.standard_models.company_news import ( + CompanyNewsQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.country_profile import ( + CountryProfileQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.crypto_historical import ( + CryptoHistoricalQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.currency_historical import ( # noqa: E402 + CurrencyHistoricalData, + CurrencyHistoricalQueryParams, +) +from openbb_core.provider.standard_models.currency_snapshots import ( # noqa: E402 + CurrencySnapshotsQueryParams, +) +from openbb_core.provider.standard_models.ecb_interest_rates import ( # noqa: E402 + EuropeanCentralBankInterestRatesParams, +) +from openbb_core.provider.standard_models.equity_ftd import EquityFtdData # noqa: E402 +from openbb_core.provider.standard_models.equity_performance import ( + EquityPerformanceQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.esg_risk_rating import ( # noqa: E402 + ESGRiskRatingData, + ESGRiskRatingQueryParams, +) +from openbb_core.provider.standard_models.etf_performance import ( + ETFPerformanceQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.ffrmc import ( + SelectedTreasuryConstantMaturityQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.financial_attributes import ( + FinancialAttributesQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.form_13FHR import Form13FHRData # noqa: E402 +from openbb_core.provider.standard_models.fred_release_table import ( # noqa: E402 + ReleaseTableQueryParams, +) +from openbb_core.provider.standard_models.futures_curve import ( # noqa: E402 + FuturesCurveQueryParams, +) +from openbb_core.provider.standard_models.futures_historical import ( + FuturesHistoricalData, # noqa: E402 +) +from openbb_core.provider.standard_models.historical_attributes import ( + HistoricalAttributesQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.index_constituents import ( + IndexConstituentsQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.insider_trading import ( # noqa: E402 + InsiderTradingData, + InsiderTradingQueryParams, +) +from openbb_core.provider.standard_models.latest_attributes import ( + LatestAttributesQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.reported_financials import ( # noqa: E402 + ReportedFinancialsData, + ReportedFinancialsQueryParams, +) +from openbb_core.provider.standard_models.spot import SpotRateQueryParams # noqa: E402 +from openbb_core.provider.standard_models.tbffr import ( + SelectedTreasuryBillQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.tmc import ( + TreasuryConstantMaturityQueryParams, # noqa: E402 +) +from openbb_core.provider.standard_models.treasury_auctions import ( # noqa: E402 + USTreasuryAuctionsQueryParams, +) +from openbb_core.provider.standard_models.weather_bulletin_download import ( # noqa: E402 + WeatherBulletinDownloadQueryParams, +) +from openbb_core.provider.standard_models.world_news import ( # noqa: E402 + WorldNewsQueryParams, +) +from openbb_core.provider.standard_models.yield_curve import ( # noqa: E402 + YieldCurveData, + YieldCurveQueryParams, +) + +pytestmark = pytest.mark.requires_pandas + + +def test_yield_curve_query_date_string_passes_through(): + q = YieldCurveQueryParams(date="2024-01-15") + assert q.date == "2024-01-15" + + +def test_yield_curve_query_date_dateobject_to_string(): + q = YieldCurveQueryParams(date=date(2024, 1, 15)) + assert q.date == "2024-01-15" + + +def test_yield_curve_query_date_list(): + q = YieldCurveQueryParams(date=["2024-01-15", "2024-02-15"]) + assert q.date == "2024-01-15,2024-02-15" + + +def test_yield_curve_query_date_csv_string(): + q = YieldCurveQueryParams(date="2024-01-15,2024-02-15") + assert q.date == "2024-01-15,2024-02-15" + + +def test_yield_curve_query_date_none(): + q = YieldCurveQueryParams(date=None) + assert q.date is None + + +def test_yield_curve_data_maturity_years_year_month(): + d = YieldCurveData(maturity="year_5_month_6") + assert d.maturity_years == pytest.approx(5.5) + + +def test_yield_curve_data_maturity_years_month_only(): + d = YieldCurveData(maturity="month_6") + assert d.maturity_years == pytest.approx(0.5) + + +def test_yield_curve_data_maturity_years_no_underscore(): + d = YieldCurveData(maturity="overnight") + assert d.maturity_years is None + + +def test_futures_curve_symbol_uppercased(): + q = FuturesCurveQueryParams(symbol="cl") + assert q.symbol == "CL" + + +def test_futures_curve_date_dateobject(): + q = FuturesCurveQueryParams(symbol="cl", date=date(2024, 1, 15)) + assert q.date == "2024-01-15" + + +def test_futures_curve_date_list(): + q = FuturesCurveQueryParams(symbol="cl", date=["2024-01-15"]) + assert q.date == "2024-01-15" + + +def test_release_table_date_dateobject(): + q = ReleaseTableQueryParams(release_id="123", date=date(2024, 1, 15)) + assert q.date == "2024-01-15" + + +def test_release_table_date_string(): + q = ReleaseTableQueryParams(release_id="123", date="2024-01-15") + assert q.date == "2024-01-15" + + +def test_release_table_date_list(): + q = ReleaseTableQueryParams(release_id="123", date=["2024-01-15"]) + assert q.date == "2024-01-15" + + +def test_release_table_date_none(): + q = ReleaseTableQueryParams(release_id="123") + assert q.date is None + + +def test_weather_bulletin_urls_csv_string(): + q = WeatherBulletinDownloadQueryParams(urls="a.url,b.url") + assert q.urls == ["a.url", "b.url"] + + +def test_weather_bulletin_urls_single_string(): + q = WeatherBulletinDownloadQueryParams(urls="a.url") + assert q.urls == ["a.url"] + + +def test_weather_bulletin_urls_dict_with_urls_key(): + q = WeatherBulletinDownloadQueryParams(urls={"urls": ["a", "b"]}) + assert q.urls == ["a", "b"] + + +def test_weather_bulletin_urls_list_passthrough(): + q = WeatherBulletinDownloadQueryParams(urls=["a", "b"]) + assert q.urls == ["a", "b"] + + +def test_weather_bulletin_urls_invalid_raises(): + with pytest.raises(Exception): + WeatherBulletinDownloadQueryParams(urls=42) + + +def test_world_news_defaults_dates_when_missing(): + q = WorldNewsQueryParams(start_date=None, end_date=None) + assert q.start_date is not None + assert q.end_date is not None + + +def test_world_news_keeps_supplied_dates(): + q = WorldNewsQueryParams(start_date="2024-01-01", end_date="2024-01-15") + assert str(q.start_date) == "2024-01-01" + assert str(q.end_date) == "2024-01-15" + + +def test_treasury_auctions_defaults_dates_when_missing(): + q = USTreasuryAuctionsQueryParams() + assert q.start_date is not None + assert q.end_date is not None + + +def test_treasury_auctions_validate_dates_passthrough_non_dict(): + marker = object() + assert USTreasuryAuctionsQueryParams.validate_dates(marker) is marker + + +def test_treasury_auctions_keeps_supplied_dates(): + q = USTreasuryAuctionsQueryParams(start_date="2024-01-01", end_date="2024-01-31") + assert str(q.start_date) == "2024-01-01" + assert str(q.end_date) == "2024-01-31" + + +def test_insider_trading_date_validate_datetime_branch(): + d = InsiderTradingData(filing_date="2024-01-01T12:30:00") + assert str(d.filing_date).startswith("2024-01-01 12:30:00") + + +def test_insider_trading_date_validate_date_and_none_branches(): + d = InsiderTradingData(filing_date="2024-01-01", transaction_date=None) + assert str(d.filing_date) == "2024-01-01" + assert d.transaction_date is None + + +def test_insider_trading_query_symbol_to_upper(): + q = InsiderTradingQueryParams(symbol="aapl") + assert q.symbol == "AAPL" + + +def test_bond_reference_uppercase_and_list_conversion(): + q = BondReferenceQueryParams(isin=["us123", "gb456"], currency=["usd", "eur"]) + assert q.isin == "US123,GB456" + assert q.currency == "USD,EUR" + + +def test_bond_reference_uppercase_string_branch(): + q = BondReferenceQueryParams(lei="abc123") + assert q.lei == "ABC123" + + +def test_bond_reference_none_passthrough(): + q = BondReferenceQueryParams(isin=None, currency=None, lei=None) + assert q.isin is None + assert q.currency is None + assert q.lei is None + + +def test_currency_historical_symbol_list_and_set_conversion(): + q1 = CurrencyHistoricalQueryParams(symbol=["eur-usd", "gbp-usd"]) + q2 = CurrencyHistoricalQueryParams(symbol={"usd-jpy"}) + assert q1.symbol == "EURUSD,GBPUSD" + assert q2.symbol == "USDJPY" + + +def test_currency_historical_symbol_string_branch(): + q = CurrencyHistoricalQueryParams(symbol="eur-usd") + assert q.symbol == "EURUSD" + + +def test_currency_historical_date_validator_datetime_and_date_branches(): + d1 = CurrencyHistoricalData(date="2024-01-01T12:30:00", close=1.0) + d2 = CurrencyHistoricalData(date="2024-01-01", close=1.0) + assert str(d1.date).startswith("2024-01-01 12:30:00") + assert str(d2.date) == "2024-01-01" + + +def test_currency_snapshots_counter_currency_conversion(): + q = CurrencySnapshotsQueryParams(base="usd", counter_currencies=["eur", "gbp"]) + assert q.base == "USD" + assert q.counter_currencies == "EUR,GBP" + + +def test_currency_snapshots_counter_currency_none(): + q = CurrencySnapshotsQueryParams(base="usd", counter_currencies=None) + assert q.counter_currencies is None + + +def test_esg_risk_rating_data_symbol_list_to_upper(): + d = ESGRiskRatingData( + symbol=["aapl", "msft"], + cik="1", + company_name="c", + industry="i", + year=2024, + esg_risk_rating="A", + industry_rank="1", + ) + assert d.symbol == "AAPL,MSFT" + + +def test_esg_risk_rating_query_and_data_string_to_upper(): + q = ESGRiskRatingQueryParams(symbol="aapl") + d = ESGRiskRatingData( + symbol="aapl", + cik="1", + company_name="c", + industry="i", + year=2024, + esg_risk_rating="A", + industry_rank="1", + ) + assert q.symbol == "AAPL" + assert d.symbol == "AAPL" + + +def test_company_filings_symbol_list_and_none_conversion(): + q1 = CompanyFilingsQueryParams(symbol=["aapl", "msft"]) + q2 = CompanyFilingsQueryParams(symbol=None) + assert q1.symbol == "AAPL,MSFT" + assert q2.symbol is None + + +def test_company_filings_symbol_string_branch(): + q = CompanyFilingsQueryParams(symbol="aapl") + assert q.symbol == "AAPL" + + +def test_company_filings_convert_date_none_branch(): + assert CompanyFilingsData.convert_date(None) is None + + +def test_release_table_date_explicit_none_triggers_validator(): + q = ReleaseTableQueryParams(release_id="123", date=None) + assert q.date is None + + +def test_futures_curve_date_none_and_csv_string(): + q_none = FuturesCurveQueryParams(symbol="cl", date=None) + q_csv = FuturesCurveQueryParams(symbol="cl", date="2024-01-15,2024-01-16") + assert q_none.date is None + assert q_csv.date == "2024-01-15,2024-01-16" + + +def test_remaining_standard_model_validators(): + assert BondIndicesQueryParams(index_type="YIELD").index_type == "yield" + assert BondTradesQueryParams(isin="us123").isin == "US123" + assert CompanyNewsQueryParams(symbol="aapl").symbol == "AAPL" + assert CountryProfileQueryParams(country="United States").country == "united_states" + assert CryptoHistoricalQueryParams(symbol="btc-usd").symbol == "BTC-USD" + assert ( + EuropeanCentralBankInterestRatesParams( + interest_rate_type="LENDING" + ).interest_rate_type + == "lending" + ) + assert EquityPerformanceQueryParams(sort="ASC").sort == "asc" + assert ETFPerformanceQueryParams(sort="DESC").sort == "desc" + assert SelectedTreasuryConstantMaturityQueryParams(maturity="10Y").maturity == "10y" + q = FinancialAttributesQueryParams( + symbol="AAPL", tag="revenue", period="ANNUAL", sort="ASC" + ) + assert q.period == "annual" + assert q.sort == "asc" + assert IndexConstituentsQueryParams(symbol="spx").symbol == "spx" + assert ( + IndexConstituentsQueryParams.__dict__["_to_upper"].__func__.__wrapped__( + IndexConstituentsQueryParams, "spx" + ) + == "SPX" + ) + assert SpotRateQueryParams(category="energy").category == "energy" + assert SelectedTreasuryBillQueryParams(maturity="6M").maturity == "6m" + assert TreasuryConstantMaturityQueryParams(maturity="3M").maturity == "3m" + + +def test_remaining_collection_none_and_direct_classmethod_branches(): + q = HistoricalAttributesQueryParams( + symbol="aapl", tag=["Revenue", "EPS"], frequency=None, sort=None + ) + assert q.tag == "revenue,eps" + assert q.frequency is None + assert q.sort is None + + q2 = LatestAttributesQueryParams(symbol="aapl", tag={"Revenue", "EPS"}) + assert q2.tag in {"revenue,eps", "eps,revenue"} + + rq = ReportedFinancialsQueryParams( + symbol="AAPL", period="ANNUAL", statement_type="INCOME" + ) + assert rq.period == "annual" + assert rq.statement_type == "income" + + assert str(EquityFtdData.date_validate(datetime(2024, 1, 1))) == "2024-01-01" + assert Form13FHRData.validate_option_type("CALL") == "call" + assert FuturesHistoricalData.date_validate("2024-01-01T00:00:00").year == 2024 + assert ReportedFinancialsData.replace_zero({"a": 0, "b": 1}) == {"a": None, "b": 1} diff --git a/openbb_platform/core/tests/provider/test_query_executor.py b/openbb_platform/core/tests/provider/test_query_executor.py index f5617d49841..ad7d032f50c 100644 --- a/openbb_platform/core/tests/provider/test_query_executor.py +++ b/openbb_platform/core/tests/provider/test_query_executor.py @@ -104,6 +104,15 @@ def test_filter_credentials_missing_dont_require(mock_query_executor): assert filtered_credentials == {} +def test_filter_credentials_none_dont_require(mock_query_executor): + provider = mock_query_executor.get_provider("test_provider") + provider.credentials = ["test_provider_api_key"] + + filtered_credentials = mock_query_executor.filter_credentials(None, provider, False) + + assert filtered_credentials == {} + + @pytest.mark.asyncio async def test_execute_success(mock_query_executor: QueryExecutor): """Test if the method can execute a query successfully.""" diff --git a/openbb_platform/core/tests/provider/test_registry.py b/openbb_platform/core/tests/provider/test_registry.py index 8d6f32b39a0..2058b2ae32b 100644 --- a/openbb_platform/core/tests/provider/test_registry.py +++ b/openbb_platform/core/tests/provider/test_registry.py @@ -1,9 +1,13 @@ """Test the Registry.""" +import warnings from unittest.mock import patch +import pytest + +from openbb_core.app.model.abstract.warning import OpenBBWarning from openbb_core.provider.abstract.provider import Provider -from openbb_core.provider.registry import Registry, RegistryLoader +from openbb_core.provider.registry import LoadingError, Registry, RegistryLoader def test_registry(): @@ -44,3 +48,50 @@ def test_registry_loader_from_extensions(): # Clear cache after test RegistryLoader.from_extensions.cache_clear() + + +def test_registry_loader_warns_on_bad_provider_when_not_debug(monkeypatch): + RegistryLoader.from_extensions.cache_clear() + + class _BadProvider: + name = None + + monkeypatch.setattr( + "openbb_core.provider.registry.ExtensionLoader", + type("L", (), {"provider_objects": {"bad": _BadProvider()}}), + ) + monkeypatch.setattr( + "openbb_core.provider.registry.Env", + type("E", (), {"DEBUG_MODE": False}), + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + registry = RegistryLoader.from_extensions() + + assert registry.providers == {} + assert any(isinstance(item.message, OpenBBWarning) for item in caught) + RegistryLoader.from_extensions.cache_clear() + + +def test_registry_loader_raises_loading_error_in_debug(monkeypatch): + RegistryLoader.from_extensions.cache_clear() + + class _BadProvider: + name = None + + monkeypatch.setattr( + "openbb_core.provider.registry.ExtensionLoader", + type("L", (), {"provider_objects": {"bad": _BadProvider()}}), + ) + monkeypatch.setattr( + "openbb_core.provider.registry.Env", + type("E", (), {"DEBUG_MODE": True}), + ) + monkeypatch.setattr( + "openbb_core.provider.registry.traceback.print_exception", lambda *_a: None + ) + + with pytest.raises(LoadingError, match="Error loading extension: bad"): + RegistryLoader.from_extensions() + RegistryLoader.from_extensions.cache_clear() diff --git a/openbb_platform/core/tests/provider/test_registry_map.py b/openbb_platform/core/tests/provider/test_registry_map.py index 39fc2ca90bd..aa74a55cf8e 100644 --- a/openbb_platform/core/tests/provider/test_registry_map.py +++ b/openbb_platform/core/tests/provider/test_registry_map.py @@ -31,11 +31,6 @@ def multi_registry_map(multi_provider_registry: Registry) -> RegistryMap: return RegistryMap(registry=multi_provider_registry) -# --------------------------------------------------------------------------- -# Credentials -# --------------------------------------------------------------------------- - - def test_credentials_are_namespaced_with_provider_prefix( registry_map: RegistryMap, fake_provider_name: str, @@ -55,11 +50,6 @@ def test_credentials_are_empty_for_provider_with_no_credentials( assert multi_registry_map.credentials[second_fake_provider_name] == [] -# --------------------------------------------------------------------------- -# available_providers / models -# --------------------------------------------------------------------------- - - def test_available_providers_is_sorted(multi_registry_map: RegistryMap): """``available_providers`` returns a sorted list of provider names.""" available = multi_registry_map.available_providers @@ -71,11 +61,6 @@ def test_models_are_keys_of_standard_extra(registry_map: RegistryMap): assert set(registry_map.models) == set(registry_map.standard_extra.keys()) -# --------------------------------------------------------------------------- -# Field bucketing — the heart of provider extension -# --------------------------------------------------------------------------- - - def test_standard_fields_land_under_openbb_bucket( registry_map: RegistryMap, fake_model_name: str ): @@ -128,11 +113,6 @@ def test_inherited_query_params_are_not_duplicated( assert primary_query == {} -# --------------------------------------------------------------------------- -# Multi-provider merging -# --------------------------------------------------------------------------- - - def test_multi_provider_same_model_each_get_their_own_bucket( multi_registry_map: RegistryMap, fake_provider_name: str, @@ -181,11 +161,6 @@ def test_provider_specific_fields_are_isolated_between_providers( assert "second_extra" not in primary_fields -# --------------------------------------------------------------------------- -# original_models — query, data, results_type -# --------------------------------------------------------------------------- - - def test_original_models_records_query_data_and_results_type( registry_map: RegistryMap, fake_provider_name: str, @@ -213,11 +188,6 @@ def test_results_type_is_none_for_single_data_return( assert record["results_type"] is None -# --------------------------------------------------------------------------- -# Validation -# --------------------------------------------------------------------------- - - def test_validate_rejects_non_data_subclass(): """``_validate`` raises ``ValueError`` for a non-``Data`` data type.""" @@ -290,11 +260,6 @@ def test_invalid_fetcher_data_type_fails_registry_map_construction( RegistryMap(registry=registry) -# --------------------------------------------------------------------------- -# Construction wiring -# --------------------------------------------------------------------------- - - def test_registry_property_round_trips( registry_map: RegistryMap, fake_registry: Registry ): @@ -323,3 +288,48 @@ def test_default_construction_uses_extension_loader(monkeypatch): assert rmap.available_providers == [] assert rmap.credentials == {} assert rmap.models == [] + + +def test_update_json_schema_extra_merges_standard_and_extra_fields(): + class _Q(QueryParams): + std: str + extra: str + __json_schema_extra__ = { + "std": {"multiple_items_allowed": True}, + "extra": {"choices": ["a", "b"]}, + "missing": {"ignored": True}, + } + + class _D(Data): + value: int + + class _F(Fetcher[_Q, list[_D]]): + query_params_type = _Q + data_type = _D + return_type = list[_D] + + @staticmethod + def transform_query(params: dict[str, Any]) -> _Q: + return _Q(**params) + + @staticmethod + def extract_data(query: _Q, credentials: dict[str, str] | None): + return [] + + @staticmethod + def transform_data(query: _Q, data, **kwargs): + return [] + + model_map = { + "openbb": {"QueryParams": {"fields": {"std": _Q.model_fields["std"]}}}, + "prov": {"QueryParams": {"fields": {"extra": _Q.model_fields["extra"]}}}, + } + + RegistryMap(registry=Registry())._update_json_schema_extra("prov", _F, model_map) + + assert model_map["openbb"]["QueryParams"]["fields"]["std"].json_schema_extra == { + "prov": {"multiple_items_allowed": True} + } + assert model_map["prov"]["QueryParams"]["fields"]["extra"].json_schema_extra == { + "prov": {"choices": ["a", "b"]} + } diff --git a/openbb_platform/core/tests/provider/utils/test_client.py b/openbb_platform/core/tests/provider/utils/test_client.py index 51134564c6c..dbe216823ae 100644 --- a/openbb_platform/core/tests/provider/utils/test_client.py +++ b/openbb_platform/core/tests/provider/utils/test_client.py @@ -1,8 +1,10 @@ """Test the client helper.""" +import asyncio import gzip import json import zlib +from unittest.mock import patch import aiohttp import pytest @@ -99,6 +101,21 @@ class MockClientSession(client.ClientSession): return response # type: ignore +class _BaseResponseMock: + def __init__(self, *, body: bytes = b"{}", encoding: str = ""): + self.headers = {"Content-Encoding": encoding} if encoding else {} + self._raw = body + self._body = None + self.status_raised = False + + async def read(self): + return self._raw + + def raise_for_status(self): + self.status_raised = True + raise RuntimeError("raised") + + @pytest.mark.parametrize( "url_params, obfuscated_params", [ @@ -204,3 +221,179 @@ async def test_client_content_encoding(): assert isinstance(response, dict) assert response == {"test": "test"} + + +def test_client_response_obfuscate_request_info_direct_call(): + url = URL("http://x?api_key=secret&symbol=AAPL") + request_info = aiohttp.RequestInfo( + url=url, + method="GET", + headers=CIMultiDictProxy(CIMultiDict({"Authorization": "Bearer xyz"})), + real_url=url, + ) + # Call obfuscation directly — verifies behavior covered by line 44-45 path. + obf = client.ClientResponse.obfuscate_request_info(request_info) + assert "********" in str(obf.url) + assert obf.headers["Authorization"] == "********" + + +def test_client_response_init_obfuscates_before_super(monkeypatch): + url = URL("http://x?api_key=secret") + request_info = aiohttp.RequestInfo( + url=url, + method="GET", + headers=CIMultiDictProxy(CIMultiDict({"Authorization": "Bearer xyz"})), + real_url=url, + ) + captured = {} + + def _fake_super_init(self, *args, **kwargs): + captured["request_info"] = kwargs["request_info"] + + monkeypatch.setattr(aiohttp.ClientResponse, "__init__", _fake_super_init) + client.ClientResponse( + "GET", + URL("http://x"), + request_info=request_info, + writer=None, + continue100=None, + timer=None, + traces=[], + loop=None, + session=None, + ) + + assert "********" in str(captured["request_info"].url) + + +def test_client_session_del_schedules_close(monkeypatch): + calls = {"count": 0} + + class _Session(client.ClientSession): + def __init__(self): + pass + + @property + def closed(self): + return False + + async def close(self): + return None + + def _create_task(coro): + calls["count"] += 1 + coro.close() + + monkeypatch.setattr(asyncio, "create_task", _create_task) + _Session().__del__() + assert calls["count"] >= 1 + + +@pytest.mark.asyncio +async def test_client_response_json_calls_super(): + """Cover ClientResponse.json (line 60).""" + + async def fake_super_json(self, **kw): + return {"ok": True} + + class Sub(client.ClientResponse): + def __init__(self): # bypass real init + pass + + with patch.object(aiohttp.ClientResponse, "json", fake_super_json): + result = await Sub().json() + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_client_request_sets_default_headers_and_user_agent(monkeypatch): + seen = {} + + async def _fake_super_request(self, *args, **kwargs): + seen["headers"] = kwargs.get("headers", {}) + return _BaseResponseMock() + + monkeypatch.setattr(aiohttp.ClientSession, "request", _fake_super_request) + + session = client.ClientSession() + try: + await session.request("GET", "http://mock.url") + finally: + await session.close() + + assert "Accept" in seen["headers"] + assert "User-Agent" in seen["headers"] + + +@pytest.mark.asyncio +async def test_client_request_respects_existing_user_agent(monkeypatch): + seen = {} + + async def _fake_super_request(self, *args, **kwargs): + seen["headers"] = kwargs.get("headers", {}) + return _BaseResponseMock() + + monkeypatch.setattr(aiohttp.ClientSession, "request", _fake_super_request) + + session = client.ClientSession() + try: + await session.request( + "GET", "http://mock.url", headers={"User-Agent": "custom-ua"} + ) + finally: + await session.close() + + assert seen["headers"]["User-Agent"] == "custom-ua" + + +@pytest.mark.asyncio +async def test_client_request_raise_for_status_branch(monkeypatch): + async def _fake_super_request(self, *args, **kwargs): + return _BaseResponseMock() + + monkeypatch.setattr(aiohttp.ClientSession, "request", _fake_super_request) + + session = client.ClientSession() + try: + with pytest.raises(RuntimeError, match="raised"): + await session.request("GET", "http://mock.url", raise_for_status=True) + finally: + await session.close() + + +@pytest.mark.asyncio +async def test_client_request_gzip_decompress_branch(monkeypatch): + payload = b'{"ok": true}' + compressed = gzip.compress(payload) + + async def _fake_super_request(self, *args, **kwargs): + return _BaseResponseMock(body=compressed, encoding="gzip") + + monkeypatch.setattr(aiohttp.ClientSession, "request", _fake_super_request) + + session = client.ClientSession(auto_decompress=False) + try: + response = await session.request("GET", "http://mock.url") + finally: + await session.close() + + assert response._body == payload + + +@pytest.mark.asyncio +async def test_client_request_deflate_decompress_branch(monkeypatch): + payload = b'{"ok": true}' + compressed = zlib.compress(payload)[2:-4] + + async def _fake_super_request(self, *args, **kwargs): + return _BaseResponseMock(body=compressed, encoding="deflate") + + monkeypatch.setattr(aiohttp.ClientSession, "request", _fake_super_request) + + session = client.ClientSession(auto_decompress=False) + try: + response = await session.request("GET", "http://mock.url") + finally: + await session.close() + + assert response._body == payload diff --git a/openbb_platform/core/tests/provider/utils/test_country_utils.py b/openbb_platform/core/tests/provider/utils/test_country_utils.py index f9e36f7133b..e13df217293 100644 --- a/openbb_platform/core/tests/provider/utils/test_country_utils.py +++ b/openbb_platform/core/tests/provider/utils/test_country_utils.py @@ -1,9 +1,15 @@ """Tests for country_utils module.""" +from unittest.mock import mock_open, patch + import pytest from pydantic import BaseModel, ValidationError -from openbb_core.provider.utils.country_utils import Country, CountryParam +from openbb_core.provider.utils.country_utils import ( + Country, + CountryParam, + _load_country_data, +) class CountryTestModel(BaseModel): @@ -190,3 +196,47 @@ class TestCountry: """Accented country names should resolve correctly.""" c = Country(input_name) assert c.alpha_2 == expected_alpha2 + + +def test_load_country_data_includes_ascii_keys(): + lookup = _load_country_data() + assert lookup["curacao"]["alpha_2"] == "CW" + assert lookup["cote_d'ivoire".replace("'", "")]["alpha_2"] == "CI" + + +def test_load_country_data_builds_ascii_lower_and_ascii_snake_keys(): + fake_data = { + "countries": [ + { + "alpha_2": "CW", + "alpha_3": "CUW", + "name": "Curaçao Islands", + "numeric": "531", + "groups": [], + } + ] + } + + with ( + patch("builtins.open", mock_open(read_data="{}")), + patch("json.load", return_value=fake_data), + ): + lookup = _load_country_data() + + assert lookup["curacao islands"]["alpha_2"] == "CW" + assert lookup["curacao_islands"]["alpha_2"] == "CW" + + +def test_lookup_country_original_case_branch(monkeypatch): + monkeypatch.setattr( + "openbb_core.provider.utils.country_utils._COUNTRY_LOOKUP", + { + "MixedCaseCountry": { + "alpha_2": "MC", + "alpha_3": "MCC", + "name": "MixedCaseCountry", + } + }, + ) + out = Country._lookup_country("MixedCaseCountry") + assert out["alpha_2"] == "MC" diff --git a/openbb_platform/core/tests/provider/utils/test_errors.py b/openbb_platform/core/tests/provider/utils/test_errors.py index 38fdd97f722..20156310f2b 100644 --- a/openbb_platform/core/tests/provider/utils/test_errors.py +++ b/openbb_platform/core/tests/provider/utils/test_errors.py @@ -38,3 +38,30 @@ def test_empty_data_error_custom_message(): with pytest.raises(EmptyDataError) as exc_info: raise EmptyDataError(custom_message) assert str(exc_info.value) == custom_message + + +def test_unauthorized_error_default_placeholder(): + """Default message keeps the placeholder when no provider is given.""" + from openbb_core.provider.utils.errors import UnauthorizedError + + e = UnauthorizedError() + assert "" in str(e) + + +def test_unauthorized_error_provider_substituted_string(): + """Provider name replaces the placeholder when message is a str.""" + from openbb_core.provider.utils.errors import UnauthorizedError + + e = UnauthorizedError( + message="Unauthorized API request.", provider_name="fmp" + ) + assert "fmp" in str(e) + assert "" not in str(e) + + +def test_unauthorized_error_provider_substituted_tuple(): + """Provider name replaces the placeholder when default tuple message is used.""" + from openbb_core.provider.utils.errors import UnauthorizedError + + e = UnauthorizedError(provider_name="polygon") + assert "polygon" in str(e) diff --git a/openbb_platform/core/tests/provider/utils/test_exchange_utils.py b/openbb_platform/core/tests/provider/utils/test_exchange_utils.py index b32b9e6b31b..459ab1ed5e5 100644 --- a/openbb_platform/core/tests/provider/utils/test_exchange_utils.py +++ b/openbb_platform/core/tests/provider/utils/test_exchange_utils.py @@ -1,6 +1,7 @@ """Tests for exchange_utils module.""" import pytest +from pydantic_core import core_schema from openbb_core.provider.utils.exchange_utils import Exchange @@ -102,3 +103,33 @@ class TestExchange: assert Exchange("XNAS").mic == Exchange("xnas").mic assert Exchange("NASDAQ").mic == Exchange("nasdaq").mic assert Exchange("Nasdaq").mic == Exchange("NASDAQ").mic + + +def test_lookup_exchange_uppercase_branch(monkeypatch): + monkeypatch.setattr( + "openbb_core.provider.utils.exchange_utils._EXCHANGE_LOOKUP", + {"XUPP": {"mic": "XUPP", "acronym": "UPP", "name": "Upper Only"}}, + ) + out = Exchange._lookup_exchange("xupp") + assert out["mic"] == "XUPP" + + +def test_lookup_exchange_original_case_branch(monkeypatch): + monkeypatch.setattr( + "openbb_core.provider.utils.exchange_utils._EXCHANGE_LOOKUP", + { + "MixedCaseExchange": { + "mic": "XMIX", + "acronym": "MIX", + "name": "MixedCaseExchange", + } + }, + ) + out = Exchange._lookup_exchange("MixedCaseExchange") + assert out["mic"] == "XMIX" + + +def test_exchange_pydantic_core_schema(): + schema = Exchange.__get_pydantic_core_schema__(None, None) + assert schema["type"] == "function-after" + assert schema["schema"] == core_schema.str_schema() diff --git a/openbb_platform/core/tests/provider/utils/test_helpers.py b/openbb_platform/core/tests/provider/utils/test_helpers.py index b701d6a966f..d9af0267773 100644 --- a/openbb_platform/core/tests/provider/utils/test_helpers.py +++ b/openbb_platform/core/tests/provider/utils/test_helpers.py @@ -1,14 +1,21 @@ """Test the provider helpers.""" +import asyncio +import datetime as dt + import pytest from openbb_core.provider.utils.client import ClientSession from openbb_core.provider.utils.helpers import ( amake_request, amake_requests, + check_item, + filter_by_dates, get_querystring, get_requests_session, make_request, + maybe_coroutine, + run_async, to_snake_case, ) @@ -140,3 +147,629 @@ async def test_amake_requests(monkeypatch): await amake_requests( ["http://mock.url", "http://mock.url"], method="PUT", raise_for_status=True ) + + +def test_safe_fromtimestamp_negative_on_non_windows(): + import os + + from openbb_core.provider.utils.helpers import safe_fromtimestamp + + if os.name == "nt": + pytest.skip("Windows-specific branch tested separately.") + # Negative timestamps are well-defined on POSIX + out = safe_fromtimestamp(-1) + assert out.year < 1971 + + +def test_safe_fromtimestamp_windows_negative_branch(monkeypatch): + """Force the os.name=='nt' branch to test the timedelta fallback path.""" + from datetime import timezone + + from openbb_core.provider.utils import helpers + + monkeypatch.setattr(helpers, "os", type("FakeOs", (), {"name": "nt"})) + out = helpers.safe_fromtimestamp(-3600, tz=timezone.utc) + assert out.year == 1969 + + +def test_safe_fromtimestamp_windows_non_negative_uses_datetime_fromtimestamp( + monkeypatch, +): + from datetime import timezone + + from openbb_core.provider.utils import helpers + + monkeypatch.setattr(helpers, "os", type("FakeOs", (), {"name": "nt"})) + out = helpers.safe_fromtimestamp(0, tz=timezone.utc) + assert out == dt.datetime(1970, 1, 1, tzinfo=timezone.utc) + + +def test_check_item_suggests_similar(): + with pytest.raises(ValueError, match="Did you mean 'apple'"): + check_item("appl", ["apple", "banana"]) + + +def test_check_item_no_similar_message(): + with pytest.raises(ValueError, match="'zzz' is not available\\."): + check_item("zzz", ["apple", "banana"], threshold=0.99) + + +def test_to_snake_case_collapses_double_underscores(): + from openbb_core.provider.utils.helpers import to_snake_case + + assert to_snake_case("SomeABCName") == "some_abc_name" + + +def test_to_snake_case_replaces_spaces(): + from openbb_core.provider.utils.helpers import to_snake_case + + assert "_" in to_snake_case("Hello World") + + +def test_combine_certificates_returns_cert_on_write_failure(tmp_path, monkeypatch): + """If shutil.copyfileobj raises, the original cert path is returned with a warning.""" + import shutil + import warnings as _warnings + + from openbb_core.provider.utils import helpers + + cert = tmp_path / "ca.pem" + cert.write_text("CERT") + bundle = tmp_path / "bundle.pem" + bundle.write_text("BUNDLE") + + def boom(*_a, **_kw): + raise OSError("disk full") + + monkeypatch.setattr(shutil, "copyfileobj", boom) + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + out = helpers.combine_certificates(str(cert), str(bundle)) + # On failure, function returns the original cert string + assert out == str(cert) + assert any("error occurred while handling" in str(w.message) for w in caught) + + +def test_combine_certificates_uses_certifi_when_no_bundle(tmp_path, monkeypatch): + """When no bundle is given, certifi.where() is used.""" + from openbb_core.provider.utils import helpers + + cert = tmp_path / "ca.pem" + cert.write_text("CERT") + bundle = tmp_path / "ca-bundle.pem" + bundle.write_text("DEFAULT-BUNDLE") + import certifi + + monkeypatch.setattr(certifi, "where", lambda: str(bundle)) + out = helpers.combine_certificates(str(cert)) + from pathlib import Path + + out_path = Path(out) + assert out_path.exists() + assert "DEFAULT-BUNDLE" in out_path.read_text() + out_path.unlink(missing_ok=True) + + +def test_get_async_requests_session_returns_provided_session(): + """A pre-existing ClientSession is returned as-is.""" + import asyncio + + from openbb_core.provider.utils.client import ClientSession + from openbb_core.provider.utils.helpers import get_async_requests_session + + async def _run(): + s = ClientSession() + try: + out = await get_async_requests_session(session=s) + assert out is s + finally: + await s.close() + + asyncio.run(_run()) + + +def test_get_async_requests_session_proxy_upgrades_https_to_http(monkeypatch): + """When env HTTP_PROXY==HTTPS_PROXY and starts with https:, it is rewritten.""" + import asyncio + + from openbb_core.provider.utils import helpers + + monkeypatch.setattr(helpers, "get_python_request_settings", lambda: {}) + monkeypatch.setenv("HTTP_PROXY", "https://shared:1") + monkeypatch.setenv("HTTPS_PROXY", "https://shared:1") + + async def _run(): + s = await helpers.get_async_requests_session() + try: + assert not s.closed + finally: + await s.close() + + asyncio.run(_run()) + + +def test_get_async_requests_session_with_basic_auth(monkeypatch): + import asyncio + + from openbb_core.provider.utils import helpers + + monkeypatch.setattr(helpers, "get_python_request_settings", lambda: {}) + + async def _run(): + s = await helpers.get_async_requests_session(auth=["user", "pass"]) + try: + assert not s.closed + finally: + await s.close() + + asyncio.run(_run()) + + +def test_get_async_requests_session_with_cookies_dict(monkeypatch): + import asyncio + + from openbb_core.provider.utils import helpers + + monkeypatch.setattr(helpers, "get_python_request_settings", lambda: {}) + + async def _run(): + s = await helpers.get_async_requests_session(cookies={"k": "v"}) + try: + assert not s.closed + finally: + await s.close() + + asyncio.run(_run()) + + +def test_get_async_requests_session_with_timeout_int(monkeypatch): + import asyncio + + from openbb_core.provider.utils import helpers + + monkeypatch.setattr(helpers, "get_python_request_settings", lambda: {"timeout": 5}) + + async def _run(): + s = await helpers.get_async_requests_session() + try: + assert not s.closed + finally: + await s.close() + + asyncio.run(_run()) + + +def test_amake_requests_raises_first_exception_when_no_results(monkeypatch): + """When all results are exceptions and no results collected, the first is raised.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock + + from openbb_core.provider.utils import helpers + + fake_session = MagicMock() + fake_session.close = AsyncMock() + monkeypatch.setattr( + helpers, "get_async_requests_session", AsyncMock(return_value=fake_session) + ) + + async def _gather(*_args, **_kw): + return [RuntimeError("first"), RuntimeError("second")] + + monkeypatch.setattr(asyncio, "gather", _gather) + + async def _run(): + with pytest.raises(RuntimeError, match="first"): + await helpers.amake_requests(["http://a", "http://b"]) + + asyncio.run(_run()) + + +def test_amake_requests_returns_exceptions_when_ret_exceptions(monkeypatch): + import asyncio + from unittest.mock import AsyncMock, MagicMock + + from openbb_core.provider.utils import helpers + + fake_session = MagicMock() + fake_session.close = AsyncMock() + monkeypatch.setattr( + helpers, "get_async_requests_session", AsyncMock(return_value=fake_session) + ) + + err = RuntimeError("boom") + + async def _gather(*_args, **_kw): + return [err, {"ok": True}] + + monkeypatch.setattr(asyncio, "gather", _gather) + + async def _run(): + out = await helpers.amake_requests( + ["http://a", "http://b"], return_exceptions=True + ) + assert err in out + assert {"ok": True} in out + + asyncio.run(_run()) + + +def test_get_async_requests_session_ssl_context_with_cert(tmp_path, monkeypatch): + """Lines 263-279: ca/cert path builds ssl_context.""" + import asyncio + + # Build self-signed cert+key for test + import subprocess + + from openbb_core.provider.utils import helpers as H + + cert = tmp_path / "c.pem" + key = tmp_path / "k.pem" + subprocess.run( # noqa: S603 + [ # noqa: S607 + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + str(key), + "-out", + str(cert), + "-days", + "1", + "-nodes", + "-subj", + "/CN=t", + ], + check=True, + capture_output=True, + ) + + async def _go(): + s = await H.get_async_requests_session( + cafile=str(cert), certfile=str(cert), keyfile=str(key) + ) + await s.close() + + asyncio.run(_go()) + + +def test_get_async_requests_session_with_basic_auth_and_cookies(): + """Lines 303, 317-318: proxy_auth/auth/dict cookies.""" + import asyncio + + from openbb_core.provider.utils import helpers as H + + async def _go(): + s = await H.get_async_requests_session( + proxy_auth=("u", "p"), + auth=("a", "b"), + cookies={"k": "v"}, + ) + await s.close() + + asyncio.run(_go()) + + +def test_get_requests_session_verify_false_and_auth_and_cookies(monkeypatch): + from openbb_core.provider.utils import helpers + + monkeypatch.setattr( + helpers, + "get_python_request_settings", + lambda: { + "verify_ssl": False, + "cookies": {"k": "v"}, + "auth": ["u", "p"], + }, + ) + sess = helpers.get_requests_session() + assert sess.verify is False + assert sess.cookies.get("k") == "v" + assert sess.auth == ("u", "p") + + +def test_get_requests_session_combine_certs_and_cert_tuple(monkeypatch, tmp_path): + from openbb_core.provider.utils import helpers + + cert = tmp_path / "ca.pem" + key = tmp_path / "key.pem" + cert.write_text("CERT") + key.write_text("KEY") + monkeypatch.setattr( + helpers, + "get_python_request_settings", + lambda: {"cafile": str(cert), "certfile": str(cert), "keyfile": str(key)}, + ) + monkeypatch.setattr(helpers, "combine_certificates", lambda *_a, **_k: "combined") + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + sess = helpers.get_requests_session() + assert sess.verify == "combined" + assert sess.cert == (str(cert), str(key)) + + +def test_get_requests_session_proxies_and_kwargs_update(monkeypatch): + from openbb_core.provider.utils import helpers + + monkeypatch.setattr(helpers, "get_python_request_settings", lambda: {"headers": {}}) + monkeypatch.setenv("HTTP_PROXY", "http://same") + monkeypatch.setenv("HTTPS_PROXY", "http://same") + sess = helpers.get_requests_session(params={"a": 1}) + assert sess.proxies["http"] == "http://same" + assert sess.proxies["https"] == "http://same" + assert sess.params["a"] == 1 + + +def test_get_requests_session_kwargs_attribute_error_branch(monkeypatch): + from openbb_core.provider.utils import helpers + + class _Bad: + headers = {} + trust_env = True + proxies = {} + verify = True + cert = None + cookies = {} + auth = None + + @property + def ro(self): + return 1 + + monkeypatch.setattr(helpers, "get_python_request_settings", lambda: {}) + monkeypatch.setattr("requests.Session", _Bad) + sess = helpers.get_requests_session(ro=2) + assert sess.trust_env is False + + +def test_get_async_requests_session_ssl_context_from_settings(monkeypatch, tmp_path): + import asyncio + import subprocess + + from openbb_core.provider.utils import helpers as H + + cert = tmp_path / "cert.pem" + key = tmp_path / "key.pem" + subprocess.run( # noqa: S603 + [ # noqa: S607 + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + str(key), + "-out", + str(cert), + "-days", + "1", + "-nodes", + "-subj", + "/CN=test", + ], + check=True, + capture_output=True, + ) + + monkeypatch.setattr( + H, + "get_python_request_settings", + lambda: {"cafile": str(cert), "certfile": str(cert), "keyfile": str(key)}, + ) + + async def _go(): + s = await H.get_async_requests_session() + await s.close() + + asyncio.run(_go()) + + +def test_get_async_requests_session_cookiejar_branch(monkeypatch): + import asyncio + + import aiohttp + + from openbb_core.provider.utils import helpers as H + + monkeypatch.setattr(H, "get_python_request_settings", lambda: {}) + + async def _go(): + jar = aiohttp.CookieJar() + jar.update_cookies({"k": "v"}) + s = await H.get_async_requests_session(cookies=jar) + await s.close() + + asyncio.run(_go()) + + +def test_get_async_requests_session_atexit_closes_orphan(monkeypatch): + import asyncio + + from openbb_core.provider.utils import helpers as H + + monkeypatch.setattr(H, "get_python_request_settings", lambda: {}) + captured = {} + + def _register(fn, session): + captured["fn"] = fn + captured["session"] = session + + monkeypatch.setattr("atexit.register", _register) + called = {"closed": 0} + monkeypatch.setattr( + H, "run_async", lambda fn: called.__setitem__("closed", called["closed"] + 1) + ) + + async def _go(): + s = await H.get_async_requests_session() + captured["fn"](s) + await s.close() + + asyncio.run(_go()) + assert called["closed"] >= 1 + + +@pytest.mark.asyncio +async def test_amake_requests_skips_falsey_results(monkeypatch): + async def _gather(*_args, **_kwargs): + return [None, {}, [], {"ok": True}] + + class _S: + async def close(self): + return None + + from openbb_core.provider.utils import helpers + + async def _session_factory(**_k): + return _S() + + monkeypatch.setattr(asyncio, "gather", _gather) + monkeypatch.setattr(helpers, "get_async_requests_session", _session_factory) + out = await helpers.amake_requests(["a", "b"]) + assert out == [{"ok": True}] + + +def test_combine_certificates_file_not_found(): + from openbb_core.provider.utils.helpers import combine_certificates + + with pytest.raises(FileNotFoundError): + combine_certificates("/no/such/cert.pem") + + +def test_combine_certificates_returns_same_for_combined_suffix(tmp_path): + from openbb_core.provider.utils.helpers import combine_certificates + + cert = tmp_path / "ca_combined.pem" + cert.write_text("CERT") + assert combine_certificates(str(cert)) == str(cert) + + +def test_combine_certificates_returns_existing_combined_file(tmp_path): + from openbb_core.provider.utils.helpers import combine_certificates + + cert = tmp_path / "ca.pem" + cert.write_text("CERT") + combined = tmp_path / "ca_combined.pem" + combined.write_text("COMBINED") + assert combine_certificates(str(cert)) == str(combined) + + +def test_make_request_timeout_from_preferences(monkeypatch): + captured = {} + + class _S: + def get(self, *_a, **kwargs): + captured["timeout"] = kwargs["timeout"] + return MockResponse() + + def post(self, *_a, **_k): + return MockResponse() + + monkeypatch.setattr( + "openbb_core.provider.utils.helpers.get_python_request_settings", lambda: {} + ) + monkeypatch.setattr( + "openbb_core.provider.utils.helpers.get_requests_session", lambda **_k: _S() + ) + make_request("http://mock.url", preferences={"request_timeout": 2}) + assert captured["timeout"] == 2 + + +def test_make_request_timeout_from_python_settings(monkeypatch): + captured = {} + + class _S: + def get(self, *_a, **kwargs): + captured["timeout"] = kwargs["timeout"] + return MockResponse() + + def post(self, *_a, **_k): + return MockResponse() + + monkeypatch.setattr( + "openbb_core.provider.utils.helpers.get_python_request_settings", + lambda: {"timeout": 3, "headers": {}}, + ) + monkeypatch.setattr( + "openbb_core.provider.utils.helpers.get_requests_session", lambda **_k: _S() + ) + make_request("http://mock.url") + assert captured["timeout"] == 3 + + +def test_make_request_post_branch(monkeypatch): + called = {"post": 0} + + class _S: + def get(self, *_a, **_k): + return MockResponse() + + def post(self, *_a, **_k): + called["post"] += 1 + return MockResponse() + + monkeypatch.setattr( + "openbb_core.provider.utils.helpers.get_python_request_settings", lambda: {} + ) + monkeypatch.setattr( + "openbb_core.provider.utils.helpers.get_requests_session", lambda **_k: _S() + ) + resp = make_request("http://mock.url", method="POST") + assert resp.status_code == 200 + assert called["post"] == 1 + + +@pytest.mark.asyncio +async def test_maybe_coroutine_both_paths(): + def _sync(x): + return x + 1 + + async def _async(x): + return x + 2 + + assert await maybe_coroutine(_sync, 1) == 2 + assert await maybe_coroutine(_async, 1) == 3 + + +def test_run_async_sync_path(): + assert run_async(lambda x: x + 1, 2) == 3 + + +def test_run_async_async_path(): + async def _coro(x): + return x + 1 + + assert run_async(_coro, 2) == 3 + + +class _Item: + def __init__(self, dt): + self.date = dt + + +def test_filter_by_dates_no_bounds_returns_input(): + data = [_Item(dt.date(2024, 1, 1))] + assert filter_by_dates(data) == data + + +def test_filter_by_dates_start_and_end(): + data = [_Item(dt.date(2024, 1, 1)), _Item(dt.date(2024, 2, 1))] + out = filter_by_dates( + data, start_date=dt.date(2024, 1, 15), end_date=dt.date(2024, 2, 2) + ) + assert len(out) == 1 + + +def test_filter_by_dates_only_start(): + data = [_Item(dt.date(2024, 1, 1)), _Item(dt.datetime(2024, 2, 1))] + out = filter_by_dates(data, start_date=dt.date(2024, 2, 1)) + assert len(out) == 1 + + +def test_filter_by_dates_only_end_and_missing_date(): + class _NoDate: + pass + + data = [_Item(dt.date(2024, 3, 1)), _NoDate()] + out = filter_by_dates(data, end_date=dt.date(2024, 2, 1)) + assert out == [] diff --git a/openbb_platform/core/tests/provider/utils/test_options_chains_properties.py b/openbb_platform/core/tests/provider/utils/test_options_chains_properties.py index 62e604963cc..2eb451036a1 100644 --- a/openbb_platform/core/tests/provider/utils/test_options_chains_properties.py +++ b/openbb_platform/core/tests/provider/utils/test_options_chains_properties.py @@ -27,11 +27,6 @@ from openbb_core.provider.standard_models.options_chains import OptionsChainsDat requires_pandas = pytest.mark.requires_pandas -# --------------------------------------------------------------------------- -# Pandas-missing guard (runs without pandas installed) -# --------------------------------------------------------------------------- - - def test_dataframe_raises_clear_error_without_pandas(monkeypatch): """``dataframe`` must surface an actionable install hint when pandas is gone.""" real_import = builtins.__import__ @@ -59,11 +54,6 @@ def test_dataframe_raises_clear_error_without_pandas(monkeypatch): _ = data.dataframe -# --------------------------------------------------------------------------- -# DataFrame analytics — require pandas -# --------------------------------------------------------------------------- - - def _build_chain( expirations: int = 2, strikes: tuple[float, ...] = (90.0, 95.0, 100.0, 105.0, 110.0), @@ -110,7 +100,7 @@ def _build_chain( @pytest.fixture -def chain(): +def chain_smoke(): return _build_chain() @@ -118,45 +108,53 @@ def chain(): class TestDataFrame: """Cover ``OptionsChainsProperties.dataframe`` cached property.""" - def test_dataframe_returns_pandas_dataframe(self, chain): + def test_dataframe_returns_pandas_dataframe(self, chain_smoke): from pandas import DataFrame - df = chain.dataframe + df = chain_smoke.dataframe assert isinstance(df, DataFrame) - def test_dataframe_row_count_matches_input(self, chain): - # 2 expirations × 5 strikes × 2 option types - assert len(chain.dataframe) == 2 * 5 * 2 + def test_dataframe_row_count_matches_input(self, chain_smoke): + df = chain_smoke.dataframe + expected = ( + df["expiration"].nunique() + * df["strike"].nunique() + * df["option_type"].nunique() + ) + assert len(df) == expected - def test_dataframe_has_expected_columns(self, chain): - df = chain.dataframe + def test_dataframe_has_expected_columns(self, chain_smoke): + df = chain_smoke.dataframe for col in ("strike", "option_type", "expiration", "bid", "ask"): assert col in df.columns - def test_dataframe_is_cached(self, chain): + def test_dataframe_is_cached(self, chain_smoke): # ``cached_property`` returns the same object on repeat access. - assert chain.dataframe is chain.dataframe + assert chain_smoke.dataframe is chain_smoke.dataframe @requires_pandas class TestFilterData: """Cover ``OptionsChainsProperties.filter_data``.""" - def test_filter_by_expiration_index(self, chain): + def test_filter_by_expiration_index(self, chain_smoke): from pandas import DataFrame # ``date`` accepts either a date string, an integer DTE, or an index. - filtered = chain.filter_data(date=0) + filtered = chain_smoke.filter_data(date=0) assert isinstance(filtered, DataFrame) - # Single expiration → 5 strikes × 2 option types. - assert len(filtered) == 10 + expected = ( + chain_smoke.dataframe["strike"].nunique() + * chain_smoke.dataframe["option_type"].nunique() + ) + assert len(filtered) == expected - def test_filter_by_option_type_calls(self, chain): - filtered = chain.filter_data(option_type="call") + def test_filter_by_option_type_calls(self, chain_smoke): + filtered = chain_smoke.filter_data(option_type="call") assert (filtered["option_type"] == "call").all() - def test_filter_by_option_type_puts(self, chain): - filtered = chain.filter_data(option_type="put") + def test_filter_by_option_type_puts(self, chain_smoke): + filtered = chain_smoke.filter_data(option_type="put") assert (filtered["option_type"] == "put").all() @@ -164,13 +162,13 @@ class TestFilterData: class TestStatAggregates: """Cover the ``_get_stat`` family — open interest / volume aggregations.""" - def test_total_oi_returns_dict_or_dataframe(self, chain): - result = chain.total_oi + def test_total_oi_returns_dict_or_dataframe(self, chain_smoke): + result = chain_smoke.total_oi # The implementation returns a dict keyed by ('Calls', 'Puts', 'Total'). assert result is not None - def test_total_volume_returns_dict_or_dataframe(self, chain): - result = chain.total_volume + def test_total_volume_returns_dict_or_dataframe(self, chain_smoke): + result = chain_smoke.total_volume assert result is not None @@ -178,47 +176,47 @@ class TestStatAggregates: class TestStrategies: """Cover the headline single-leg & spread strategy builders.""" - def test_straddle_returns_dataframe(self, chain): + def test_straddle_returns_dataframe(self, chain_smoke): from pandas import DataFrame - result = chain.straddle() + result = chain_smoke.straddle() assert isinstance(result, DataFrame) assert not result.empty - def test_strangle_returns_dataframe(self, chain): + def test_strangle_returns_dataframe(self, chain_smoke): from pandas import DataFrame - result = chain.strangle() + result = chain_smoke.strangle() assert isinstance(result, DataFrame) assert not result.empty - def test_synthetic_long_returns_dataframe(self, chain): + def test_synthetic_long_returns_dataframe(self, chain_smoke): from pandas import DataFrame - result = chain.synthetic_long() + result = chain_smoke.synthetic_long() assert isinstance(result, DataFrame) assert not result.empty - def test_synthetic_short_returns_dataframe(self, chain): + def test_synthetic_short_returns_dataframe(self, chain_smoke): from pandas import DataFrame - result = chain.synthetic_short() + result = chain_smoke.synthetic_short() assert isinstance(result, DataFrame) assert not result.empty - def test_vertical_call_spread_returns_dataframe(self, chain): + def test_vertical_call_spread_returns_dataframe(self, chain_smoke): from pandas import DataFrame # Buy 95 call, sell 105 call on the nearest expiration. - result = chain.vertical_call_spread(sold=105.0, bought=95.0) + result = chain_smoke.vertical_call_spread(sold=105.0, bought=95.0) assert isinstance(result, DataFrame) assert not result.empty - def test_vertical_put_spread_returns_dataframe(self, chain): + def test_vertical_put_spread_returns_dataframe(self, chain_smoke): from pandas import DataFrame # Buy 105 put, sell 95 put. - result = chain.vertical_put_spread(sold=95.0, bought=105.0) + result = chain_smoke.vertical_put_spread(sold=95.0, bought=105.0) assert isinstance(result, DataFrame) assert not result.empty @@ -227,9 +225,551 @@ class TestStrategies: class TestSkew: """Cover ``OptionsChainsProperties.skew``.""" - def test_skew_returns_dataframe(self, chain): + def test_skew_returns_dataframe(self, chain_smoke): + from pandas import DataFrame + + result = chain_smoke.skew() + assert isinstance(result, DataFrame) + assert not result.empty + + +"""Extended tests for ``OptionsChainsProperties`` - target high-line-count gaps.""" + + +import pytest + +from openbb_core.provider.standard_models.options_chains import OptionsChainsData + +requires_pandas = pytest.mark.requires_pandas + + +def _build_chain( + expirations: int = 3, + strikes=(80.0, 90.0, 95.0, 100.0, 105.0, 110.0, 120.0), + underlying_price: float = 100.0, + with_greeks: bool = True, + with_eod: bool = True, +) -> OptionsChainsData: + rows = [] + base = date.today() + timedelta(days=14) + for e in range(expirations): + exp = base + timedelta(days=30 * e) + for k in strikes: + for opt in ("call", "put"): + intrinsic = ( + max(0.0, underlying_price - k) + if opt == "call" + else max(0.0, k - underlying_price) + ) + bid = round(intrinsic + 1.0, 2) + ask = round(bid + 0.10, 2) + row = { + "underlying_symbol": "FAKE", + "underlying_price": underlying_price, + "contract_symbol": ( + f"FAKE{exp:%y%m%d}{opt[0].upper()}{int(k * 1000):08d}" + ), + "expiration": exp, + "strike": k, + "option_type": opt, + "open_interest": 100 + int(k), + "volume": 50 + int(k), + "bid": bid, + "ask": ask, + "implied_volatility": 0.25 + (k - underlying_price) / 1000, + } + if with_eod: + row["eod_date"] = date.today() + if with_greeks: + row["delta"] = 0.5 if opt == "call" else -0.5 + row["gamma"] = 0.05 + row["theta"] = -0.02 + row["vega"] = 0.1 + row["rho"] = 0.01 + rows.append(row) + + columns = {key: [r.get(key) for r in rows] for key in rows[0]} + return OptionsChainsData(**columns) + + +@pytest.fixture +def chain(): + return _build_chain() + + +@pytest.fixture +def chain_no_greeks(): + return _build_chain(with_greeks=False) + + +@requires_pandas +class TestLastPrice: + def test_last_price_setter_and_deleter(self, chain): + assert chain.last_price is None + chain.last_price = 99.5 + assert chain.last_price == 99.5 + del chain.last_price + assert chain.last_price is None + + def test_dataframe_uses_last_price_override(self): + c = _build_chain() + c.last_price = 200.0 + df = c.dataframe + assert (df["underlying_price"] == 200.0).all() + + def test_dataframe_missing_underlying_price_raises(self): + data = OptionsChainsData( + contract_symbol=["FAKE"], + expiration=[date.today() + timedelta(days=30)], + strike=[100.0], + option_type=["call"], + underlying_symbol=["FAKE"], + bid=[1.0], + ask=[1.1], + ) + + with pytest.raises(OpenBBError, match="underlying_price"): + _ = data.dataframe + + def test_dataframe_empty_raises(self): + data = _build_chain() + data.model_dump = lambda **kwargs: {"underlying_price": []} + + with pytest.raises(OpenBBError, match="No validated data"): + _ = data.dataframe + + def test_dataframe_without_eod_date_builds_dte(self): + df = _build_chain(with_eod=False).dataframe + + assert "dte" in df.columns + + def test_dataframe_falls_back_to_raw_frame_on_processing_error( + self, monkeypatch, chain + ): + monkeypatch.setattr( + type(chain), + "_identify_price_col", + lambda *args, **kwargs: (_ for _ in ()).throw(KeyError("missing")), + ) + + df = chain.dataframe + + assert "Breakeven" not in df.columns + + +@requires_pandas +class TestExpirationsStrikesFlags: + def test_expirations_sorted(self, chain): + exps = chain.expirations + assert exps == sorted(exps) + + def test_strikes_sorted(self, chain): + s = chain.strikes + assert s == sorted(s) + + def test_has_iv_true(self, chain): + assert chain.has_iv is True + + def test_has_greeks_true_false(self, chain, chain_no_greeks): + assert chain.has_greeks is True + assert chain_no_greeks.has_greeks is False + + +@requires_pandas +class TestTotalDexGex: + def test_total_dex_returns(self, chain): + result = chain.total_dex + assert result is not None + + def test_total_gex_returns(self, chain): + result = chain.total_gex + assert result is not None + + def test_total_dex_no_greeks_raises(self, chain_no_greeks): + with pytest.raises(OpenBBError, match="Greeks are not available"): + _ = chain_no_greeks.total_dex + + def test_total_gex_no_greeks_raises(self, chain_no_greeks): + with pytest.raises(OpenBBError, match="Greeks are not available"): + _ = chain_no_greeks.total_gex + + +@requires_pandas +class TestFilterDataExtended: + def test_filter_by_stat_open_interest(self, chain): + from pandas import DataFrame + + result = chain.filter_data(stat="open_interest") + assert isinstance(result, DataFrame) + + def test_filter_by_stat_volume(self, chain): + from pandas import DataFrame + + result = chain.filter_data(stat="volume") + assert isinstance(result, DataFrame) + + def test_filter_by_stat_dex(self, chain): + from pandas import DataFrame + + result = chain.filter_data(stat="dex") + assert isinstance(result, DataFrame) + + def test_filter_by_stat_gex(self, chain): + from pandas import DataFrame + + result = chain.filter_data(stat="gex") + assert isinstance(result, DataFrame) + + def test_filter_invalid_stat_raises(self, chain): + with pytest.raises(OpenBBError, match="stat must be one of"): + chain.filter_data(stat="bogus") + + def test_filter_by_moneyness(self, chain): + from pandas import DataFrame + + result = chain.filter_data(moneyness="otm") + assert isinstance(result, DataFrame) + + def test_filter_by_date(self, chain): + from pandas import DataFrame + + result = chain.filter_data(date=0) + assert isinstance(result, DataFrame) + + def test_filter_by_column_min_max(self, chain): + from pandas import DataFrame + + result = chain.filter_data(column="open_interest", value_min=100, value_max=200) + assert isinstance(result, DataFrame) + assert result["open_interest"].abs().between(100, 200).all() + + def test_filter_by_column_min_only(self, chain): + result = chain.filter_data(column="open_interest", value_min=150) + assert (result["open_interest"].abs() >= 150).all() + + def test_filter_by_column_max_only(self, chain): + result = chain.filter_data(column="open_interest", value_max=150) + assert (result["open_interest"].abs() <= 150).all() + + def test_filter_by_column_no_bounds_sorts(self, chain): + from pandas import DataFrame + + result = chain.filter_data(column="open_interest") + assert isinstance(result, DataFrame) + + def test_filter_unknown_column_raises(self, chain): + with pytest.raises(OpenBBError, match="not found in data"): + chain.filter_data(column="not_a_column") + + def test_filter_stat_missing_underlying_price_raises(self, chain): + df = chain.dataframe.drop(columns=["underlying_price", "DEX", "GEX"]) + chain.__dict__["dataframe"] = df + + with pytest.raises(OpenBBError, match="underlying price was not returned"): + chain.filter_data(stat="gex") + + +@requires_pandas +class TestStrategiesMethod: + def test_strategies_default(self, chain): + from pandas import DataFrame + + result = chain.strategies() + assert isinstance(result, DataFrame) + + def test_strategies_strangle(self, chain): + from pandas import DataFrame + + result = chain.strategies(strangle_moneyness=[5.0, 10.0]) + assert isinstance(result, DataFrame) + + def test_strategies_synthetic_long(self, chain): + from pandas import DataFrame + + result = chain.strategies(synthetic_longs=[100.0]) + assert isinstance(result, DataFrame) + + def test_strategies_synthetic_short(self, chain): + from pandas import DataFrame + + result = chain.strategies(synthetic_shorts=[100.0]) + assert isinstance(result, DataFrame) + + def test_strategies_vertical_calls(self, chain): + from pandas import DataFrame + + result = chain.strategies(vertical_calls=[(105.0, 95.0)]) + assert isinstance(result, DataFrame) + + def test_strategies_vertical_puts(self, chain): + from pandas import DataFrame + + result = chain.strategies(vertical_puts=[(95.0, 105.0)]) + assert isinstance(result, DataFrame) + + def test_strategies_all_at_once(self, chain): + from pandas import DataFrame + + result = chain.strategies( + days=[20, 40], + straddle_strike=100.0, + strangle_moneyness=[5.0], + synthetic_longs=[100.0], + synthetic_shorts=[100.0], + vertical_calls=[(105.0, 95.0)], + vertical_puts=[(95.0, 105.0)], + ) + assert isinstance(result, DataFrame) + + def test_strategies_days_minus_one(self, chain): + result = chain.strategies(days=-1, straddle_strike=100.0) + assert result is not None + + +@requires_pandas +class TestSkewExtended: + def test_skew_vertical_default(self, chain): from pandas import DataFrame result = chain.skew() assert isinstance(result, DataFrame) - assert not result.empty + + def test_skew_horizontal(self, chain): + from pandas import DataFrame + + result = chain.skew(moneyness=10.0) + assert isinstance(result, DataFrame) + + def test_skew_specific_date(self, chain): + from pandas import DataFrame + + result = chain.skew(date=30) + assert isinstance(result, DataFrame) + + +@requires_pandas +class TestGetStatBranches: + def test_get_stat_with_moneyness_otm(self, chain): + result = chain._get_stat("open_interest", moneyness="otm") + assert "Calls" in result["total"] or "expiration" in result + + def test_get_stat_with_moneyness_itm(self, chain): + result = chain._get_stat("open_interest", moneyness="itm") + assert result is not None + + def test_get_stat_with_date(self, chain): + exp = chain.expirations[0] + result = chain._get_stat("open_interest", date=exp) + assert result is not None + + def test_get_stat_dex_no_greeks_raises(self, chain_no_greeks): + with pytest.raises(OpenBBError, match="Greeks were not found"): + chain_no_greeks._get_stat("DEX") + + +@requires_pandas +class TestNearestHelpers: + def test_get_nearest_expiration_without_dte(self, chain): + df = chain.dataframe.drop(columns=["dte"]) + + with pytest.raises(TypeError): + chain._get_nearest_expiration(10, df=df) + + def test_get_nearest_expiration_with_none_date(self, chain): + assert chain._get_nearest_expiration(None) in chain.expirations + + def test_get_nearest_otm_strikes_default_moneyness(self, chain): + strikes = chain._get_nearest_otm_strikes(moneyness=None) + + assert set(strikes) == {"call", "put"} + + def test_get_nearest_otm_strikes_invalid_moneyness(self, chain): + with pytest.raises(OpenBBError, match="between 0 and 100"): + chain._get_nearest_otm_strikes(moneyness=101) + + def test_get_nearest_otm_strikes_missing_underlying_price_raises(self, chain): + chain.__dict__["dataframe"] = chain.dataframe.drop(columns=["underlying_price"]) + + with pytest.raises(OpenBBError, match="underlying_price must be provided"): + chain._get_nearest_otm_strikes() + + def test_get_nearest_strike_invalid_option_type(self, chain): + with pytest.raises(OpenBBError, match="option_type must be either"): + chain._get_nearest_strike("bad") + + def test_get_nearest_strike_with_none_days(self, chain): + assert ( + chain._get_nearest_strike("call", days=None, strike=100.0, force_otm=False) + is not None + ) + + def test_get_nearest_strike_returns_none_for_empty_chain(self, monkeypatch, chain): + monkeypatch.setattr( + chain, "_get_nearest_expiration", lambda *args, **kwargs: "2099-01-01" + ) + + assert chain._get_nearest_strike("call", days=30, strike=100.0) is None + + def test_get_nearest_strike_returns_none_when_no_otm_match(self, chain): + assert ( + chain._get_nearest_strike("put", days=30, strike=1.0, force_otm=True) + is None + ) + + +@requires_pandas +class TestStrategyEdges: + def test_straddle_days_zero_and_short(self, chain): + zero_day = chain.straddle(days=0) + short = chain.straddle(strike=-100.0) + + assert not zero_day.empty + assert short.columns[0] == "Short Straddle" + + def test_straddle_missing_underlying_price_raises(self, chain): + chain.__dict__["dataframe"] = chain.dataframe.drop(columns=["underlying_price"]) + + with pytest.raises(OpenBBError, match="underlying_price must be provided"): + chain.straddle() + + def test_straddle_missing_strike_raises_when_underlying_override_supplied( + self, chain + ): + chain.__dict__["dataframe"] = chain.dataframe.drop(columns=["underlying_price"]) + + with pytest.raises(OpenBBError, match="strike must be provided"): + chain.straddle(underlying_price=100.0) + + def test_straddle_missing_premium_raises(self, monkeypatch, chain): + monkeypatch.setattr(chain, "_get_nearest_strike", lambda *args, **kwargs: 999.0) + + with pytest.raises(OpenBBError, match="No premium data found"): + chain.straddle() + + def test_strangle_short_and_missing_premium(self, monkeypatch, chain): + short = chain.strangle(moneyness=-5.0) + monkeypatch.setattr(chain, "_get_nearest_strike", lambda *args, **kwargs: 999.0) + + assert short.columns[0] == "Short Strangle" + with pytest.raises(OpenBBError, match="No premium data found"): + chain.strangle(moneyness=5.0) + + def test_strangle_missing_underlying_price_raises(self, chain): + chain.__dict__["dataframe"] = chain.dataframe.drop(columns=["underlying_price"]) + + with pytest.raises(OpenBBError, match="underlying_price must be provided"): + chain.strangle() + + def test_vertical_call_spread_default_bear_and_empty(self, chain): + default = chain.vertical_call_spread(days=0) + bear = chain.vertical_call_spread(sold=95.0, bought=105.0) + empty = chain.vertical_call_spread(sold=100.0, bought=100.0) + + assert not default.empty + assert bear.columns[0] == "Bear Call Spread" + assert empty.empty + + def test_vertical_call_spread_missing_underlying_price_raises(self, chain): + chain.__dict__["dataframe"] = chain.dataframe.drop(columns=["underlying_price"]) + + with pytest.raises(OpenBBError, match="underlying_price must be provided"): + chain.vertical_call_spread() + + def test_vertical_put_spread_default_and_empty(self, chain): + default = chain.vertical_put_spread(days=0) + empty = chain.vertical_put_spread(sold=100.0, bought=100.0) + + assert not default.empty + assert empty.empty + + def test_vertical_put_spread_missing_underlying_price_raises(self, chain): + chain.__dict__["dataframe"] = chain.dataframe.drop(columns=["underlying_price"]) + + with pytest.raises(OpenBBError, match="underlying_price must be provided"): + chain.vertical_put_spread() + + def test_synthetic_long_edges(self, monkeypatch, chain): + assert not chain.synthetic_long(days=None).empty + assert not chain.synthetic_long(days=0).empty + + monkeypatch.setattr(chain, "_get_nearest_strike", lambda *args, **kwargs: 999.0) + with pytest.raises(OpenBBError, match="No premium data found"): + chain.synthetic_long() + + def test_synthetic_long_missing_underlying_price_raises(self, chain): + chain.__dict__["dataframe"] = chain.dataframe.drop(columns=["underlying_price"]) + + with pytest.raises(OpenBBError, match="underlying_price must be provided"): + chain.synthetic_long() + + def test_synthetic_short_edges(self, monkeypatch, chain): + assert not chain.synthetic_short(days=0).empty + + monkeypatch.setattr(chain, "_get_nearest_strike", lambda *args, **kwargs: 999.0) + with pytest.raises(OpenBBError, match="No premium data found"): + chain.synthetic_short() + + def test_synthetic_short_missing_underlying_price_raises(self, chain): + chain.__dict__["dataframe"] = chain.dataframe.drop(columns=["underlying_price"]) + + with pytest.raises(OpenBBError, match="underlying_price must be provided"): + chain.synthetic_short() + + def test_strategies_tuple_and_flat_list_inputs(self, chain): + result = chain.strategies( + vertical_calls=(105.0, 95.0), vertical_puts=[95.0, 105.0] + ) + + assert result is not None + + def test_strategies_raises_when_all_results_empty(self, monkeypatch, chain): + from pandas import DataFrame + + monkeypatch.setattr(chain, "straddle", lambda *args, **kwargs: DataFrame()) + monkeypatch.setattr(chain, "strangle", lambda *args, **kwargs: DataFrame()) + monkeypatch.setattr( + chain, "synthetic_long", lambda *args, **kwargs: DataFrame() + ) + monkeypatch.setattr( + chain, "synthetic_short", lambda *args, **kwargs: DataFrame() + ) + monkeypatch.setattr( + chain, "vertical_call_spread", lambda *args, **kwargs: DataFrame() + ) + monkeypatch.setattr( + chain, "vertical_put_spread", lambda *args, **kwargs: DataFrame() + ) + + with pytest.raises(OpenBBError, match="No strategies found"): + chain.strategies(straddle_strike=100.0, vertical_calls=(105.0, 95.0)) + + +@requires_pandas +class TestSkewEdges: + def test_skew_requires_implied_volatility(self, monkeypatch, chain): + monkeypatch.setattr(type(chain), "has_iv", property(lambda self: False)) + + with pytest.raises(OpenBBError, match="implied_volatility"): + chain.skew() + + def test_skew_missing_underlying_price_raises(self, chain): + chain.__dict__["dataframe"] = chain.dataframe.drop(columns=["underlying_price"]) + + with pytest.raises(OpenBBError, match="underlying_price must be provided"): + chain.skew(moneyness=10.0) + + def test_skew_horizontal_not_enough_information_raises(self, chain): + df = chain.dataframe.copy() + df.loc[df.option_type == "put", "implied_volatility"] = 0 + chain.__dict__["dataframe"] = df + + with pytest.raises(OpenBBError, match="Not enough information"): + chain.skew(moneyness=10.0) + + def test_skew_vertical_not_enough_information_raises(self, chain): + df = chain.dataframe.copy() + df.loc[df.option_type == "put", "implied_volatility"] = 0 + chain.__dict__["dataframe"] = df + + with pytest.raises(OpenBBError, match="Not enough information"): + chain.skew(date=30, moneyness=None) diff --git a/openbb_platform/core/tests/test_env.py b/openbb_platform/core/tests/test_env.py new file mode 100644 index 00000000000..94f0fc0f692 --- /dev/null +++ b/openbb_platform/core/tests/test_env.py @@ -0,0 +1,23 @@ +from openbb_core.env import Env + + +def test_str2bool_invalid_value_raises(): + try: + Env.str2bool("maybe") + assert False + except ValueError as exc: + assert "Failed to cast maybe to bool" in str(exc) + + +def test_str2bool_false_value(): + assert Env.str2bool("false") is False + + +def test_auto_build_property_uses_env_value(): + env = Env() + original = env._environ.copy() + try: + env._environ["OPENBB_AUTO_BUILD"] = "true" + assert env.AUTO_BUILD is True + finally: + env._environ = original