"""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..pid.`` 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