Files
OpenBB/cli/conftest.py
Danglewood 9a30186174 [V5] CLI 2.0 - Pluggable Backends, Non-TTY Default, HTTP Dispatcher, Spec Files + Codegen (#7433)
* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* cap test matrix at python 3.14

* no useless comments

* platform controller command description split

* merge URL overloads from path params

* exclude none and unset from model dump

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
2026-06-01 19:14:38 +03:00

277 lines
9.3 KiB
Python

"""Session-scoped fixture that builds & installs a synthetic OpenBB extension.
Every test under ``cli/`` (both ``cli/tests/`` and ``cli/integration/``) runs
against the same generated static-package surface. The fixture writes a
self-contained extension distribution to disk, ``pip install``s it into the
current interpreter, and runs ``openbb-build`` so the static package under
``openbb_platform/core/openbb/package/`` is regenerated with the test
extension's commands baked in.
Modeled directly on ``openbb_platform/core/integration/conftest.py``; reuses
the same snapshot-and-restore approach so the production package dir is
returned to its pre-test state.
Because the static package is wired via entry points discovered at
*interpreter start*, tests that import ``openbb`` after the build must do so
in a fresh ``python`` subprocess. The ``run_in_obb`` helper does exactly
that.
"""
from __future__ import annotations
import contextlib
import json
import os
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
CORE_DIR = REPO_ROOT / "openbb_platform" / "core"
OPENBB_DIR = CORE_DIR / "openbb"
PACKAGE_DIR = OPENBB_DIR / "package"
ASSETS_DIR = OPENBB_DIR / "assets"
EXT_NAME = "openbb-cli-fake"
EXT_PKG = "openbb_cli_fake"
PYPROJECT = """\
[project]
name = "{ext_name}"
version = "0.0.1"
requires-python = ">=3.10,<4"
dependencies = ["openbb-core"]
[project.entry-points."openbb_core_extension"]
cli_test = "{pkg}.router:router"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["{pkg}"]
"""
INIT_PY = '''\
"""Synthetic extension exposing deterministic commands for openbb-cli tests."""
'''
ROUTER_PY = '''\
"""Router for the openbb-cli synthetic test extension.
Exposes a small surface under ``obb.cli_test.*`` so the CLI's dispatcher,
controller, and output-adapter tests can resolve real commands through the
production ``obb`` namespace — same code path as installed extensions.
"""
from typing import Any
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.router import Router
router = Router(prefix="", description="openbb-cli synthetic test extension.")
@router.command(methods=["GET"])
async def echo(value: str = "hi") -> OBBject[dict[str, Any]]:
"""Return the input wrapped in an OBBject — sync-style payload."""
return OBBject(results={"echo": value})
@router.command(methods=["GET"])
async def quote(symbol: str) -> OBBject[dict[str, Any]]:
"""Return a deterministic quote-shaped payload, async-style."""
return OBBject(results={"symbol": symbol, "async": True})
@router.command(methods=["GET"])
async def bomb() -> OBBject[dict[str, Any]]:
"""Raise to exercise dispatcher error isolation."""
raise RuntimeError("boom")
@router.command(methods=["GET"])
async def rows(n: int = 3) -> OBBject[list[dict[str, Any]]]:
"""Return a deterministic list payload — exercises tabular output adapters."""
return OBBject(results=[{"i": i, "v": i * 2} for i in range(n)])
'''
def _snapshot_dir(src: Path, dst: Path) -> None:
if src.exists():
shutil.copytree(src, dst, dirs_exist_ok=True)
def _restore_dir(src: Path, dst: Path) -> None:
shutil.rmtree(dst, ignore_errors=True)
if src.exists():
shutil.copytree(src, dst)
def _pip(*args: str) -> None:
subprocess.run( # noqa: S603
[sys.executable, "-m", "pip", *args],
check=True,
capture_output=True,
text=True,
)
def _subprocess_env(extra: dict[str, str] | None = None) -> dict[str, str]:
"""Build a subprocess env that opts into coverage.py auto-startup if available.
Strips ``COV_CORE_*`` so the child does not try to bootstrap via
``pytest_cov.embed.init()`` (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 = Path(__file__).resolve().parent / ".coveragerc"
if rcfile.exists():
env["COVERAGE_PROCESS_START"] = str(rcfile)
if extra:
env.update(extra)
return env
def _run_openbb_build() -> None:
"""Trigger ``openbb-build`` via auto-build on first import in a fresh subprocess.
A child process is required because ``ExtensionLoader``/``RouterLoader``
cache entry points at module load time; the parent process cannot
discover the freshly-installed distribution without a restart.
"""
env = _subprocess_env({"OPENBB_AUTO_BUILD": "true"})
subprocess.run( # noqa: S603
[sys.executable, "-c", "import openbb"],
check=True,
capture_output=True,
text=True,
env=env,
)
@pytest.fixture(scope="session", autouse=True)
def cli_fake_extension_installed(tmp_path_factory):
"""Build, install, openbb-build, yield, then uninstall and restore.
Auto-used at session scope so every test in ``cli/`` runs against the
same generated static package surface. The synthetic extension exposes
``obb.cli_test.{echo,quote,bomb,rows}`` for dispatcher / output / controller
tests to resolve through the real plugin-discovery path.
"""
work = tmp_path_factory.mktemp("cli_fake_ext_dist")
pkg_dir = work / EXT_PKG
pkg_dir.mkdir()
(pkg_dir / "__init__.py").write_text(INIT_PY, encoding="utf-8")
(pkg_dir / "router.py").write_text(ROUTER_PY, encoding="utf-8")
(work / "pyproject.toml").write_text(
PYPROJECT.format(ext_name=EXT_NAME, pkg=EXT_PKG), encoding="utf-8"
)
snapshot_root = tmp_path_factory.mktemp("openbb_snapshot")
pkg_snapshot = snapshot_root / "package"
assets_snapshot = snapshot_root / "assets"
_snapshot_dir(PACKAGE_DIR, pkg_snapshot)
_snapshot_dir(ASSETS_DIR, assets_snapshot)
try:
_pip("install", "--no-deps", "-e", str(work))
if str(work) not in sys.path:
sys.path.insert(0, str(work))
_run_openbb_build()
ext_pkg_module = PACKAGE_DIR / "cli_test.py"
if not ext_pkg_module.exists():
pytest.fail(
"openbb-build did not emit cli_test.py; "
f"contents of {PACKAGE_DIR}: "
f"{sorted(p.name for p in PACKAGE_DIR.glob('*.py'))}"
)
yield {
"ext_name": EXT_NAME,
"ext_pkg": EXT_PKG,
"package_dir": PACKAGE_DIR,
"assets_dir": ASSETS_DIR,
"work_dir": work,
}
finally:
with contextlib.suppress(subprocess.CalledProcessError):
_pip("uninstall", "-y", EXT_NAME)
with contextlib.suppress(ValueError):
sys.path.remove(str(work))
for mod_name in [m for m in sys.modules if m.startswith(EXT_PKG)]:
del sys.modules[mod_name]
_restore_dir(pkg_snapshot, PACKAGE_DIR)
_restore_dir(assets_snapshot, ASSETS_DIR)
_combine_partial_coverage()
def _combine_partial_coverage() -> None:
"""Merge ``.coverage.<host>.pid<N>.<rand>`` partials and erase them.
``parallel = True`` in ``.coveragerc`` makes every subprocess (the
``_run_openbb_build`` build call and every ``run_in_obb`` snippet) write a
uniquely-named partial. Without ``coverage combine`` they accumulate at
the repo root across runs. Run combine if any partial exists; otherwise
no-op so suites that don't use the subprocess path stay clean.
"""
here = Path(__file__).resolve().parent
if not list(here.glob(".coverage.*")):
return
with contextlib.suppress(subprocess.CalledProcessError, FileNotFoundError):
subprocess.run( # noqa: S603
[sys.executable, "-m", "coverage", "combine"],
check=False,
capture_output=True,
text=True,
cwd=str(here),
)
for partial in here.glob(".coverage.*"):
with contextlib.suppress(OSError):
partial.unlink()
@pytest.fixture(scope="session")
def run_in_obb(cli_fake_extension_installed): # noqa: ARG001
"""Run an inline snippet under a fresh subprocess that imports ``openbb``.
Returns a callable ``run(snippet: str) -> dict``. The snippet must assign
a JSON-serializable value to ``RESULT``; the wrapper ``json.dumps`` it to
stdout and the helper parses it back into Python.
Use this for any test that needs to resolve commands via the real
generated static ``obb`` namespace — entry-point caching makes
in-process resolution unreliable after a fresh build.
"""
def _run(snippet: str) -> dict:
wrapper = textwrap.dedent("""\
import json, sys
from openbb import obb # noqa: F401
{snippet}
sys.stdout.write(json.dumps(RESULT, default=str))
""").format(snippet=textwrap.dedent(snippet).strip())
proc = subprocess.run( # noqa: S603
[sys.executable, "-c", wrapper],
check=False,
capture_output=True,
text=True,
env=_subprocess_env({"OPENBB_AUTO_BUILD": "false"}),
)
if proc.returncode != 0:
raise AssertionError(
f"obb subprocess failed (rc={proc.returncode})\n"
f"--- stdout ---\n{proc.stdout}\n"
f"--- stderr ---\n{proc.stderr}"
)
return json.loads(proc.stdout)
return _run