mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 20:42:26 +00:00
* 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>
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""Tests for openbb_cli.config.loader — layered TOML resolution + env injection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from openbb_cli.config import loader
|
|
|
|
|
|
def _write(path: Path, contents: str) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(contents)
|
|
return path
|
|
|
|
|
|
# --- load_config layering ---
|
|
|
|
|
|
def test_load_config_returns_empty_when_no_sources(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("OPENBB_CLI_CONFIG", raising=False)
|
|
monkeypatch.setattr(loader, "_user_global_toml", lambda: None)
|
|
assert loader.load_config(start=tmp_path) == {}
|
|
|
|
|
|
def test_load_config_normalizes_kebab_to_snake(tmp_path, monkeypatch):
|
|
_write(tmp_path / "openbb.toml", "batch-concurrency = 4\n")
|
|
monkeypatch.delenv("OPENBB_CLI_CONFIG", raising=False)
|
|
monkeypatch.setattr(loader, "_user_global_toml", lambda: None)
|
|
out = loader.load_config(start=tmp_path)
|
|
assert out["batch_concurrency"] == 4
|
|
|
|
|
|
def test_load_config_explicit_path_wins(tmp_path, monkeypatch):
|
|
_write(tmp_path / "openbb.toml", 'server = "from-local"\n')
|
|
explicit = _write(tmp_path / "explicit.toml", 'server = "from-explicit"\n')
|
|
monkeypatch.delenv("OPENBB_CLI_CONFIG", raising=False)
|
|
monkeypatch.setattr(loader, "_user_global_toml", lambda: None)
|
|
out = loader.load_config(explicit_path=str(explicit), start=tmp_path)
|
|
assert out["server"] == "from-explicit"
|
|
|
|
|
|
def test_load_config_env_var_picks_up_path(tmp_path, monkeypatch):
|
|
explicit = _write(tmp_path / "viaenv.toml", 'server = "from-env"\n')
|
|
monkeypatch.setenv("OPENBB_CLI_CONFIG", str(explicit))
|
|
monkeypatch.setattr(loader, "_user_global_toml", lambda: None)
|
|
out = loader.load_config(start=tmp_path)
|
|
assert out["server"] == "from-env"
|
|
|
|
|
|
def test_load_config_pyproject_section_loaded(tmp_path, monkeypatch):
|
|
_write(
|
|
tmp_path / "pyproject.toml",
|
|
'[tool.openbb-cli]\nserver = "https://from-pyproject"\n',
|
|
)
|
|
monkeypatch.delenv("OPENBB_CLI_CONFIG", raising=False)
|
|
monkeypatch.setattr(loader, "_user_global_toml", lambda: None)
|
|
out = loader.load_config(start=tmp_path)
|
|
assert out["server"] == "https://from-pyproject"
|
|
|
|
|
|
def test_load_config_project_local_overrides_pyproject(tmp_path, monkeypatch):
|
|
_write(
|
|
tmp_path / "pyproject.toml",
|
|
'[tool.openbb-cli]\nserver = "from-pyproject"\n',
|
|
)
|
|
_write(tmp_path / "openbb.toml", 'server = "from-local"\n')
|
|
monkeypatch.delenv("OPENBB_CLI_CONFIG", raising=False)
|
|
monkeypatch.setattr(loader, "_user_global_toml", lambda: None)
|
|
out = loader.load_config(start=tmp_path)
|
|
assert out["server"] == "from-local"
|
|
|
|
|
|
def test_load_config_deep_merges_nested_tables(tmp_path, monkeypatch):
|
|
"""Nested ``[headers]`` / ``[query]`` tables merge per-key, not whole-table."""
|
|
_write(
|
|
tmp_path / "pyproject.toml",
|
|
'[tool.openbb-cli.headers]\nA = "1"\nB = "from-pyproject"\n',
|
|
)
|
|
_write(tmp_path / "openbb.toml", '[headers]\nB = "overridden"\nC = "3"\n')
|
|
monkeypatch.delenv("OPENBB_CLI_CONFIG", raising=False)
|
|
monkeypatch.setattr(loader, "_user_global_toml", lambda: None)
|
|
out = loader.load_config(start=tmp_path)
|
|
assert out["headers"] == {"A": "1", "B": "overridden", "C": "3"}
|
|
|
|
|
|
def test_read_toml_returns_empty_on_invalid_content(tmp_path):
|
|
"""A malformed TOML file is treated as an empty layer, not raised."""
|
|
bad = _write(tmp_path / "bad.toml", "this is not toml = =\n")
|
|
assert loader._read_toml(bad) == {}
|
|
|
|
|
|
def test_find_first_walks_up_to_ancestor(tmp_path):
|
|
"""Discovery walks up from a nested directory until it finds the file."""
|
|
_write(tmp_path / "openbb.toml", 'server = "x"\n')
|
|
nested = tmp_path / "a" / "b" / "c"
|
|
nested.mkdir(parents=True)
|
|
found = loader._find_first(nested, ("openbb.toml", ".openbb.toml"))
|
|
assert found == tmp_path / "openbb.toml"
|
|
|
|
|
|
def test_find_first_returns_none_when_no_match(tmp_path):
|
|
assert loader._find_first(tmp_path, ("nonsense.toml",)) is None
|
|
|
|
|
|
# --- apply_settings_to_env ---
|
|
|
|
|
|
def test_apply_settings_to_env_handles_none_or_empty():
|
|
assert loader.apply_settings_to_env(None) == []
|
|
assert loader.apply_settings_to_env({}) == []
|
|
|
|
|
|
def test_apply_settings_to_env_translates_kebab_keys(monkeypatch):
|
|
monkeypatch.delenv("OPENBB_ALLOWED_NUMBER_OF_ROWS", raising=False)
|
|
applied = loader.apply_settings_to_env({"settings": {"allowed-number-of-rows": 50}})
|
|
assert "OPENBB_ALLOWED_NUMBER_OF_ROWS" in applied
|
|
assert os.environ["OPENBB_ALLOWED_NUMBER_OF_ROWS"] == "50"
|
|
|
|
|
|
def test_apply_settings_to_env_serializes_booleans_capitalized(monkeypatch):
|
|
monkeypatch.delenv("OPENBB_USE_PROMPT_TOOLKIT", raising=False)
|
|
loader.apply_settings_to_env({"settings": {"use-prompt-toolkit": True}})
|
|
assert os.environ["OPENBB_USE_PROMPT_TOOLKIT"] == "True"
|
|
|
|
|
|
def test_apply_settings_to_env_settings_table_does_not_clobber_existing(monkeypatch):
|
|
"""Values from the ``[settings]`` table use ``setdefault`` semantics."""
|
|
monkeypatch.setenv("OPENBB_ALLOWED_NUMBER_OF_ROWS", "999")
|
|
loader.apply_settings_to_env({"settings": {"allowed-number-of-rows": 50}})
|
|
assert os.environ["OPENBB_ALLOWED_NUMBER_OF_ROWS"] == "999"
|
|
|
|
|
|
def test_apply_settings_to_env_top_level_overrides_settings_table(monkeypatch):
|
|
"""Top-level promoted keys (snake_case after normalization) override [settings]."""
|
|
monkeypatch.delenv("OPENBB_OUTPUT_MODE", raising=False)
|
|
# Mimic what `load_config()` produces: top-level keys arrive snake_cased
|
|
loader.apply_settings_to_env(
|
|
{
|
|
"output_mode": "rich",
|
|
"settings": {"output-mode": "tsv"},
|
|
}
|
|
)
|
|
assert os.environ["OPENBB_OUTPUT_MODE"] == "rich"
|
|
|
|
|
|
# --- load_env_files ---
|
|
|
|
|
|
def test_load_env_files_loads_explicit_path(tmp_path, monkeypatch):
|
|
env = _write(tmp_path / ".env", "OPENBB_FROM_DOTENV=hello\n")
|
|
monkeypatch.delenv("OPENBB_FROM_DOTENV", raising=False)
|
|
# Block discovery of the user-global .env so this test stays hermetic
|
|
monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path / "no-such-dir")
|
|
loaded = loader.load_env_files(str(env))
|
|
assert env in loaded
|
|
assert os.environ["OPENBB_FROM_DOTENV"] == "hello"
|
|
|
|
|
|
def test_load_env_files_does_not_override_existing(tmp_path, monkeypatch):
|
|
env = _write(tmp_path / ".env", "OPENBB_KEEP_EXISTING=from-dotenv\n")
|
|
monkeypatch.setenv("OPENBB_KEEP_EXISTING", "from-shell")
|
|
monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path / "no-such-dir")
|
|
loader.load_env_files(str(env))
|
|
assert os.environ["OPENBB_KEEP_EXISTING"] == "from-shell"
|
|
|
|
|
|
def test_load_env_files_silently_skips_missing(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path / "no-such-dir")
|
|
assert loader.load_env_files("/nonexistent/path/.env") == []
|
|
|
|
|
|
def test_load_env_files_uses_env_var_when_no_explicit(tmp_path, monkeypatch):
|
|
env = _write(tmp_path / ".env", "OPENBB_FROM_ENV_VAR=yes\n")
|
|
monkeypatch.delenv("OPENBB_FROM_ENV_VAR", raising=False)
|
|
monkeypatch.setenv(loader.EXPLICIT_ENV_FILE_ENV, str(env))
|
|
monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path / "no-such-dir")
|
|
loader.load_env_files()
|
|
assert os.environ["OPENBB_FROM_ENV_VAR"] == "yes"
|
|
|
|
|
|
# --- render_config_template ---
|
|
|
|
|
|
def test_render_config_template_returns_valid_toml():
|
|
"""The template must round-trip through tomllib when nothing's been set."""
|
|
rendered = loader.render_config_template()
|
|
# Strip the all-comment lines so tomllib has something to parse — even
|
|
# an empty result is fine; the assertion is "no parse error".
|
|
if loader.sys.version_info >= (3, 11):
|
|
import tomllib
|
|
else: # pragma: no cover
|
|
import tomli as tomllib
|
|
tomllib.loads(rendered)
|
|
|
|
|
|
def test_render_config_template_uncomments_active_values():
|
|
"""When ``active`` resolves a value, the line is emitted uncommented."""
|
|
rendered = loader.render_config_template({"server": "https://api.example.com"})
|
|
# The server line should NOT be commented out
|
|
server_lines = [
|
|
line for line in rendered.splitlines() if line.strip().startswith("server =")
|
|
]
|
|
assert server_lines
|
|
assert not server_lines[0].lstrip().startswith("#")
|
|
assert "api.example.com" in server_lines[0]
|
|
|
|
|
|
def test_render_config_template_includes_headers_and_query_sections():
|
|
rendered = loader.render_config_template()
|
|
assert "[headers]" in rendered
|
|
assert "[query]" in rendered
|
|
assert "[settings]" in rendered
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,expected",
|
|
[
|
|
(True, "true"),
|
|
(False, "false"),
|
|
(42, "42"),
|
|
("plain", '"plain"'),
|
|
('with "quote"', '"with \\"quote\\""'),
|
|
],
|
|
)
|
|
def test_toml_quote_serializes_basic_types(value, expected):
|
|
assert loader._toml_quote(value) == expected
|
|
|
|
|
|
def test_normalize_keys_kebab_to_snake_top_level_only():
|
|
"""Top-level keys are kebab→snake; nested header/query keys keep casing."""
|
|
out = loader._normalize_keys(
|
|
{"batch-concurrency": 8, "headers": {"X-API-Key": "v"}}
|
|
)
|
|
assert "batch_concurrency" in out
|
|
# Nested header keys preserved as-is — they're literal HTTP header names
|
|
assert "X-API-Key" in out["headers"]
|
|
|
|
|
|
# --- _user_global_toml + load_env_files ---
|
|
|
|
|
|
def test_user_global_toml_finds_file_when_dir_exists(tmp_path, monkeypatch):
|
|
"""``_user_global_toml`` returns the path when the user dir + file both exist."""
|
|
monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path)
|
|
expected = tmp_path / "openbb.toml"
|
|
expected.write_text("server = 'x'\n")
|
|
assert loader._user_global_toml() == expected
|
|
|
|
|
|
def test_user_global_toml_returns_none_when_dir_exists_no_file(tmp_path, monkeypatch):
|
|
"""Directory exists but no openbb.toml inside → None."""
|
|
monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path)
|
|
assert loader._user_global_toml() is None
|
|
|
|
|
|
def test_load_config_includes_user_global_layer(tmp_path, monkeypatch):
|
|
"""The user-global TOML layer is merged below project-local."""
|
|
user_toml = tmp_path / "openbb.toml"
|
|
user_toml.write_text('server = "from-user-global"\n')
|
|
monkeypatch.setattr(loader, "_user_global_toml", lambda: user_toml)
|
|
monkeypatch.delenv("OPENBB_CLI_CONFIG", raising=False)
|
|
out = loader.load_config(start=tmp_path / "no-such-dir")
|
|
assert out["server"] == "from-user-global"
|
|
|
|
|
|
def test_load_env_files_imports_dotenv_values(tmp_path, monkeypatch):
|
|
"""Real ``.env`` parsing path: values land in os.environ via setdefault."""
|
|
env = tmp_path / ".env"
|
|
env.write_text("OPENBB_LOADER_REAL=yes\nNULL_VALUE\n")
|
|
monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path / "no-user")
|
|
monkeypatch.delenv("OPENBB_LOADER_REAL", raising=False)
|
|
monkeypatch.delenv("NULL_VALUE", raising=False)
|
|
loader.load_env_files(str(env))
|
|
assert os.environ["OPENBB_LOADER_REAL"] == "yes"
|
|
# Bare ``NULL_VALUE`` (no equals) parses as None and is skipped, not set
|
|
assert "NULL_VALUE" not in os.environ
|
|
|
|
|
|
def test_load_env_files_picks_up_user_global_env(tmp_path, monkeypatch):
|
|
"""``USER_OPENBB_DIR/.env`` is added to the candidate list when present —
|
|
the user-global layer is what makes ``~/.openbb_platform/.env`` work
|
|
without any explicit path argument or env var."""
|
|
user_dir = tmp_path / "user"
|
|
user_dir.mkdir()
|
|
(user_dir / loader.USER_OPENBB_ENV_NAME).write_text(
|
|
"OPENBB_LOADER_USER_GLOBAL=present\n"
|
|
)
|
|
monkeypatch.setattr(loader, "USER_OPENBB_DIR", user_dir)
|
|
monkeypatch.delenv("OPENBB_LOADER_USER_GLOBAL", raising=False)
|
|
monkeypatch.delenv(loader.EXPLICIT_ENV_FILE_ENV, raising=False)
|
|
loader.load_env_files()
|
|
assert os.environ["OPENBB_LOADER_USER_GLOBAL"] == "present"
|
|
|
|
|
|
# --- render_config_template with active values ---
|
|
|
|
|
|
def test_render_config_template_renders_active_headers_table():
|
|
rendered = loader.render_config_template(
|
|
{"headers": {"Authorization": "Bearer xyz", "X-Tenant": "acme"}}
|
|
)
|
|
assert '"Authorization" = "Bearer xyz"' in rendered
|
|
assert '"X-Tenant" = "acme"' in rendered
|
|
|
|
|
|
def test_render_config_template_renders_active_query_table():
|
|
rendered = loader.render_config_template({"query": {"api_key": "secret"}})
|
|
assert '"api_key" = "secret"' in rendered
|
|
|
|
|
|
# --- _render_settings_section dev field branch ---
|
|
|
|
|
|
def test_render_settings_section_includes_dev_fields():
|
|
"""Dev-mode fields (DEBUG_MODE, TEST_MODE) render in the [settings] section."""
|
|
rendered = loader.render_config_template()
|
|
# DEBUG_MODE / TEST_MODE flagged as dev in _SETTINGS_DEV_FIELDS
|
|
assert "debug-mode" in rendered.lower() or "test-mode" in rendered.lower()
|
|
|
|
|
|
# --- _toml_quote numeric branch ---
|
|
|
|
|
|
@pytest.mark.parametrize("value,expected", [(0, "0"), (1.5, "1.5"), (-3, "-3")])
|
|
def test_toml_quote_serializes_numbers(value, expected):
|
|
assert loader._toml_quote(value) == expected
|
|
|
|
|
|
def test_user_global_toml_returns_none_when_dir_missing(tmp_path, monkeypatch):
|
|
"""No directory exists at all → None."""
|
|
monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path / "absent")
|
|
assert loader._user_global_toml() is None
|
|
|
|
|
|
def test_render_settings_section_skips_undocumented_internal_field():
|
|
"""Fields without ``command`` metadata in ``json_schema_extra`` AND not in
|
|
the dev allowlist are excluded from the rendered template."""
|
|
rendered = loader.render_config_template()
|
|
# ``OPENBB_VERSION`` is internal — must NOT appear in the [settings] block
|
|
assert "version =" not in rendered.lower().replace(" =", "=")
|
|
|
|
|
|
def test_toml_quote_falls_back_to_quoted_repr_for_unknown_type():
|
|
"""Anything not bool/int/float/str/list goes through ``str(...)`` and is quoted."""
|
|
assert loader._toml_quote(None) == '"None"'
|
|
assert loader._toml_quote((1, 2)) == '"(1, 2)"'
|
|
|
|
|
|
def test_render_settings_section_skips_undocumented_non_dev_field(monkeypatch):
|
|
"""A field that's not internal, not documented (no ``command`` extra),
|
|
and not in the dev allowlist is skipped. Defensive coverage — every
|
|
real Settings field is currently either documented or in the dev
|
|
allowlist; this guards against future regressions if a non-documented
|
|
field gets added without classification.
|
|
"""
|
|
from openbb_cli.models.settings import Settings
|
|
|
|
class _StubField:
|
|
json_schema_extra = None # not a dict → not documented
|
|
default = None
|
|
description = "stub"
|
|
|
|
monkeypatch.setattr(
|
|
Settings,
|
|
"model_fields",
|
|
{**Settings.model_fields, "STUB_UNDOCUMENTED": _StubField()},
|
|
)
|
|
rendered = loader.render_config_template()
|
|
assert "stub-undocumented" not in rendered.lower()
|