Files
OpenBB/cli/tests/test_config_loader.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

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()