mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 12:40:35 +00:00
498 lines
17 KiB
Python
498 lines
17 KiB
Python
"""Tests for openbb_cli.codegen.package_gen — top-level orchestrator."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from openbb_cli.codegen import package_gen as pkg
|
|
|
|
# --- _slugify ---
|
|
|
|
|
|
def test_slugify_lowercases_and_collapses_special_chars():
|
|
assert pkg._slugify("US-Congress") == "us_congress"
|
|
assert pkg._slugify("Foo Bar/Baz") == "foo_bar_baz"
|
|
|
|
|
|
def test_slugify_falls_back_when_only_separators():
|
|
assert pkg._slugify("___") == "extension"
|
|
assert pkg._slugify("") == "extension"
|
|
|
|
|
|
# --- generate_packages (orchestrator) ---
|
|
|
|
|
|
def _spec_with_get_command():
|
|
return {
|
|
"base_url": "https://api.example.com/",
|
|
"api_prefix": "api/v1",
|
|
"commands": {
|
|
"equity.search": {
|
|
"providers": ["fmp"],
|
|
"method": "get",
|
|
"url_path": "/equity/search",
|
|
"description": "Search.",
|
|
"parameters": [
|
|
{
|
|
"name": "scope",
|
|
"type": "string",
|
|
"required": True,
|
|
"choices": ["all", "active"],
|
|
},
|
|
],
|
|
"response_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"results": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"symbol": {"type": "string"}},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
|
|
def test_generate_packages_returns_single_package_with_provider_and_router(tmp_path):
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
assert isinstance(out, pkg.GeneratedPackageSet)
|
|
assert len(out.packages) == 1
|
|
package = out.packages[0]
|
|
assert isinstance(package, pkg.GeneratedPackage)
|
|
|
|
# Project metadata
|
|
assert package.project.project_name == "openbb-fmp"
|
|
assert package.project.package_name == "openbb_fmp"
|
|
# Provider entry registered
|
|
provider_names = [p.provider_name for p in package.providers]
|
|
assert "fmp" in provider_names
|
|
# Single root router named after the provider — every namespace mounts
|
|
# under ``obb.fmp.*`` so the extension never shadows openbb-core's
|
|
# first-party ``obb.equity.*`` etc.
|
|
assert package.top_level_routers == ["fmp"]
|
|
# The per-namespace router file still exists; it's just not an entry point
|
|
sub_module_names = {r.module_name for r in package.routers.routers}
|
|
assert {"fmp", "equity"} <= sub_module_names
|
|
# Fetcher registered for fmp
|
|
assert package.fetchers_by_provider["fmp"][0].model_name == "EquitySearch"
|
|
|
|
|
|
def test_generate_packages_top_level_command_mounts_on_root_router(tmp_path):
|
|
spec = {
|
|
"base_url": "https://api.example.com",
|
|
"api_prefix": "",
|
|
"commands": {
|
|
"eod": {
|
|
"providers": ["eodhd"],
|
|
"method": "get",
|
|
"url_path": "/eod/{ticker}",
|
|
"description": "End of day.",
|
|
"parameters": [
|
|
{
|
|
"name": "ticker",
|
|
"in": "path",
|
|
"type": "string",
|
|
"required": True,
|
|
}
|
|
],
|
|
"response_schema": {"type": "object"},
|
|
}
|
|
},
|
|
}
|
|
out = pkg.generate_packages(spec, output_root=tmp_path, provider_name="eodhd")
|
|
package = out.packages[0]
|
|
by_module = {r.module_name: r for r in package.routers.routers}
|
|
# No dedicated (empty) router module for the top-level command
|
|
assert "eod" not in by_module
|
|
root = by_module["eodhd"]
|
|
assert '@router.command(model="Eod")' in root.source
|
|
assert "async def eod(" in root.source
|
|
assert "from openbb_core.app.query import Query" in root.source
|
|
|
|
|
|
def test_generate_packages_skips_coverage_namespace(tmp_path):
|
|
spec = {
|
|
"base_url": "https://api.example.com",
|
|
"api_prefix": "",
|
|
"commands": {
|
|
"coverage.providers": {"providers": ["openbb"], "method": "get"},
|
|
"equity.search": {
|
|
"providers": ["fmp"],
|
|
"method": "get",
|
|
"url_path": "/equity/search",
|
|
"response_schema": {"type": "object"},
|
|
},
|
|
},
|
|
}
|
|
out = pkg.generate_packages(spec, output_root=tmp_path, provider_name="fmp")
|
|
assert "coverage" not in out.packages[0].top_level_routers
|
|
|
|
|
|
def test_generate_packages_emits_post_command_under_tools_provider(tmp_path):
|
|
spec = {
|
|
"base_url": "https://api.example.com",
|
|
"api_prefix": "",
|
|
"commands": {
|
|
"econometrics.regression": {
|
|
"providers": [], # local-compute
|
|
"method": "post",
|
|
"url_path": "/econometrics/regression",
|
|
"description": "OLS.",
|
|
"request_body_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"v": {"type": "number"}},
|
|
},
|
|
}
|
|
},
|
|
"required": ["data"],
|
|
},
|
|
"response_schema": {"type": "object"},
|
|
}
|
|
},
|
|
}
|
|
out = pkg.generate_packages(spec, output_root=tmp_path, provider_name="tools")
|
|
pack = out.packages[0]
|
|
# Synthetic ``tools`` provider added when there are POST commands
|
|
assert any(p.provider_name == "tools" for p in pack.providers)
|
|
assert len(pack.post_commands) == 1
|
|
assert pack.post_commands[0].function_name == "econometrics_regression"
|
|
|
|
|
|
def test_generate_packages_falls_back_to_synthetic_provider_when_spec_has_none(
|
|
tmp_path,
|
|
):
|
|
spec = {
|
|
"base_url": "https://api.example.com",
|
|
"api_prefix": "",
|
|
"commands": {
|
|
"equity.search": {
|
|
"providers": [], # no provider on the command either
|
|
"method": "get",
|
|
"url_path": "/equity/search",
|
|
"response_schema": {"type": "object"},
|
|
}
|
|
},
|
|
}
|
|
out = pkg.generate_packages(spec, output_root=tmp_path, provider_name="myprov")
|
|
pack = out.packages[0]
|
|
# Synthetic provider takes the slugified provider_name
|
|
assert any(p.provider_name == "myprov" for p in pack.providers)
|
|
|
|
|
|
def test_generate_packages_skips_command_with_provider_list_no_owners(tmp_path):
|
|
spec = {
|
|
"base_url": "https://api.example.com",
|
|
"api_prefix": "",
|
|
"commands": {
|
|
"equity.search": {
|
|
"providers": ["nope"], # ``nope`` not in target_providers
|
|
"method": "get",
|
|
"url_path": "/equity/search",
|
|
"response_schema": {"type": "object"},
|
|
},
|
|
# second command keeps ``fmp`` in target_providers so the
|
|
# ``no owners`` branch (line 290) actually fires
|
|
"equity.quote": {
|
|
"providers": ["fmp"],
|
|
"method": "get",
|
|
"url_path": "/equity/quote",
|
|
"response_schema": {"type": "object"},
|
|
},
|
|
},
|
|
}
|
|
out = pkg.generate_packages(spec, output_root=tmp_path, provider_name="x")
|
|
pack = out.packages[0]
|
|
# ``equity.search`` has no owners (no ``nope`` in targets) → skipped
|
|
# ``equity.quote`` belongs to fmp → only that fetcher emitted
|
|
fmp_fetchers = pack.fetchers_by_provider.get("fmp", [])
|
|
assert {f.model_name for f in fmp_fetchers} == {"EquityQuote"}
|
|
|
|
|
|
def test_generate_packages_respects_explicit_project_overrides(tmp_path):
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
project_name="my-custom-name",
|
|
package_name="my_pkg",
|
|
description="Custom desc.",
|
|
website="https://custom.example",
|
|
version="1.2.3",
|
|
)
|
|
project = out.packages[0].project
|
|
assert project.project_name == "my-custom-name"
|
|
assert project.package_name == "my_pkg"
|
|
assert "Custom desc." in project.source
|
|
assert "1.2.3" in project.source
|
|
|
|
|
|
# --- GeneratedPackage.write + _readme + GeneratedPackageSet.write ---
|
|
|
|
|
|
def test_generated_package_set_write_materializes_full_project_on_disk(tmp_path):
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
written_paths = out.write()
|
|
assert len(written_paths) == 1
|
|
project_root = written_paths[0]
|
|
assert project_root.exists()
|
|
|
|
# pyproject.toml + README + package layout
|
|
assert (project_root / "pyproject.toml").exists()
|
|
assert (project_root / "README.md").exists()
|
|
pkg_dir = project_root / "openbb_fmp"
|
|
assert (pkg_dir / "__init__.py").exists()
|
|
assert (pkg_dir / "providers" / "__init__.py").exists()
|
|
assert (pkg_dir / "routers" / "__init__.py").exists()
|
|
# Provider source written
|
|
fmp_init = pkg_dir / "providers" / "fmp" / "__init__.py"
|
|
assert fmp_init.exists()
|
|
assert "fmp_provider" in fmp_init.read_text()
|
|
# Fetcher module written
|
|
assert (pkg_dir / "providers" / "fmp" / "models" / "equity_search.py").exists()
|
|
# Router written
|
|
assert (pkg_dir / "routers" / "equity.py").exists()
|
|
|
|
|
|
def test_generated_package_write_creates_tools_dir_when_post_commands_present(tmp_path):
|
|
spec = {
|
|
"base_url": "https://api.example.com",
|
|
"api_prefix": "",
|
|
"commands": {
|
|
"econometrics.regression": {
|
|
"providers": [],
|
|
"method": "post",
|
|
"url_path": "/r",
|
|
"description": "OLS.",
|
|
"request_body_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"v": {"type": "number"}},
|
|
},
|
|
}
|
|
},
|
|
"required": ["data"],
|
|
},
|
|
"response_schema": {"type": "object"},
|
|
}
|
|
},
|
|
}
|
|
out = pkg.generate_packages(spec, output_root=tmp_path, provider_name="tools")
|
|
out.write()
|
|
project_root = out.packages[0].root
|
|
tools_models = project_root / "openbb_tools" / "providers" / "tools" / "models"
|
|
assert tools_models.exists()
|
|
assert (tools_models / "econometrics_regression.py").exists()
|
|
|
|
|
|
def test_generated_package_write_does_not_overwrite_existing_init_files(tmp_path):
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
package = out.packages[0]
|
|
package.root.mkdir(parents=True, exist_ok=True)
|
|
pkg_dir = package.root / "openbb_fmp"
|
|
pkg_dir.mkdir(parents=True, exist_ok=True)
|
|
custom_init = pkg_dir / "__init__.py"
|
|
custom_init.write_text("# user-edited content\n")
|
|
|
|
package.write()
|
|
|
|
# The pre-existing init file is preserved (write skips when file exists)
|
|
assert custom_init.read_text() == "# user-edited content\n"
|
|
|
|
|
|
def test_generated_package_readme_lists_providers_and_routers(tmp_path):
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
readme = out.packages[0]._readme()
|
|
assert "openbb-fmp" in readme
|
|
assert "**fmp**" in readme
|
|
assert "obb.fmp.*" in readme
|
|
assert "pip install -e ." in readme
|
|
|
|
|
|
def test_generated_package_set_write_with_no_packages_returns_empty_list():
|
|
empty = pkg.GeneratedPackageSet(packages=[])
|
|
assert empty.write() == []
|
|
|
|
|
|
# --- README content + ruff post-processing ---
|
|
|
|
|
|
def _spec_with_credentials():
|
|
spec = _spec_with_get_command()
|
|
spec["commands"]["equity.search"]["parameters"].append(
|
|
{"name": "apikey", "type": "string", "in": "query"}
|
|
)
|
|
return spec
|
|
|
|
|
|
def test_generated_package_readme_renders_command_tree_and_quick_start(tmp_path):
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
readme = out.packages[0]._readme()
|
|
# Quick start block points at the actual command surface
|
|
assert "from openbb import obb" in readme
|
|
assert "obb.fmp.equity.search" in readme
|
|
# Commands section includes the dotted path under the right top group
|
|
assert "## Commands" in readme
|
|
assert "### `obb.fmp.equity`" in readme
|
|
assert "`obb.fmp.equity.search` — provider: `fmp`" in readme
|
|
|
|
|
|
def test_generated_package_readme_renders_credentials_section(tmp_path):
|
|
out = pkg.generate_packages(
|
|
_spec_with_credentials(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
readme = out.packages[0]._readme()
|
|
assert "## Credentials" in readme
|
|
assert "`apikey`" in readme
|
|
|
|
|
|
def test_generated_package_readme_handles_provider_with_no_commands(tmp_path):
|
|
"""``_first_command_example`` skips providers whose command list is empty."""
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
package = out.packages[0]
|
|
# Inject an empty-command provider — the example helper must keep walking.
|
|
package.commands_by_provider = {"empty_prov": [], **package.commands_by_provider}
|
|
example = package._first_command_example("fmp")
|
|
assert example is not None
|
|
assert example[0] == "equity.search"
|
|
|
|
|
|
def test_generated_package_readme_returns_none_when_no_commands_at_all(tmp_path):
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
package = out.packages[0]
|
|
package.commands_by_provider = {}
|
|
assert package._first_command_example("fmp") is None
|
|
# README still renders; just no Quick start snippet
|
|
readme = package._readme()
|
|
assert "from openbb import obb" not in readme
|
|
|
|
|
|
def test_generated_package_write_runs_ruff_when_available(tmp_path, monkeypatch):
|
|
"""The post-write step shells out to ``ruff check`` and ``ruff format``."""
|
|
calls: list[list[str]] = []
|
|
|
|
def _fake_run(cmd, **kwargs):
|
|
calls.append(list(cmd))
|
|
|
|
class _Result:
|
|
returncode = 0
|
|
|
|
return _Result()
|
|
|
|
monkeypatch.setattr(pkg, "_find_ruff", lambda: "/fake/ruff")
|
|
monkeypatch.setattr(pkg.subprocess, "run", _fake_run)
|
|
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
out.write()
|
|
|
|
assert any("check" in c for c in calls)
|
|
assert any("format" in c for c in calls)
|
|
assert all(c[0] == "/fake/ruff" for c in calls)
|
|
|
|
|
|
def test_generated_package_write_skips_ruff_when_not_installed(tmp_path, monkeypatch):
|
|
"""Missing ``ruff`` binary -> silent no-op."""
|
|
monkeypatch.setattr(pkg, "_find_ruff", lambda: None)
|
|
called: list[list[str]] = []
|
|
monkeypatch.setattr(
|
|
pkg.subprocess,
|
|
"run",
|
|
lambda cmd, **k: called.append(list(cmd)),
|
|
)
|
|
|
|
out = pkg.generate_packages(
|
|
_spec_with_get_command(),
|
|
output_root=tmp_path,
|
|
provider_name="fmp",
|
|
)
|
|
out.write()
|
|
assert called == []
|
|
|
|
|
|
def test_find_ruff_prefers_sibling_binary(tmp_path, monkeypatch):
|
|
"""``ruff`` next to the running Python wins over the PATH lookup."""
|
|
fake_python = tmp_path / "python"
|
|
fake_python.write_text("")
|
|
fake_ruff = tmp_path / "ruff"
|
|
fake_ruff.write_text("")
|
|
monkeypatch.setattr(pkg.sys, "executable", str(fake_python))
|
|
monkeypatch.setattr(pkg.shutil, "which", lambda name: "/elsewhere/ruff")
|
|
assert pkg._find_ruff() == str(fake_ruff)
|
|
|
|
|
|
def test_find_ruff_falls_back_to_path_lookup(tmp_path, monkeypatch):
|
|
"""No sibling ruff -> shutil.which result is returned."""
|
|
fake_python = tmp_path / "python"
|
|
fake_python.write_text("")
|
|
monkeypatch.setattr(pkg.sys, "executable", str(fake_python))
|
|
monkeypatch.setattr(pkg.shutil, "which", lambda name: "/found/on/path/ruff")
|
|
assert pkg._find_ruff() == "/found/on/path/ruff"
|
|
|
|
|
|
def test_find_ruff_returns_none_when_unavailable(tmp_path, monkeypatch):
|
|
fake_python = tmp_path / "python"
|
|
fake_python.write_text("")
|
|
monkeypatch.setattr(pkg.sys, "executable", str(fake_python))
|
|
monkeypatch.setattr(pkg.shutil, "which", lambda name: None)
|
|
assert pkg._find_ruff() is None
|
|
|
|
|
|
def test_build_root_router_imports_stream_and_post_processing_functions():
|
|
router = pkg._build_root_router(
|
|
package_name="openbb_x",
|
|
root_namespace="x",
|
|
sub_routers=[],
|
|
root_commands=["@router.command()\nasync def live(): ..."],
|
|
root_post_imports=[("openbb_x.post", "shape_rows")],
|
|
root_has_stream=True,
|
|
)
|
|
assert "from openbb_core.app.model.stream import OBBStream" in router.source
|
|
assert "from openbb_x.post import shape_rows as _shape_rows" in router.source
|
|
assert router.source.index("_shape_rows") < router.source.index(
|
|
'router = Router(prefix="")'
|
|
)
|