mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-28 21:12:16 +00:00
* refactor openbb-sec for v5 * linting * ty * normalize compare_company_facts * add workflows * python 3.10 fix * add more thorough url validation and tests * ty check * ty for python 3.10 * reconfigure with apps * temp pyproject overrride * pyproject * pyproject * pyproject * codespell * pyproject * fix uk schema file links in schema_files * test coverage * test_cli * port #7547 into this pr * no hard coded prefix * improve filing rendering capabilities * fix xml text detection * make codeql happy * missed a file in the commit * test coverage * codespell * fix flakey test * some updates * management_ownership --> management_profiles * proxy statement markdown * ruff * fix test coverage * more tests * test coverage * fix lint errors * Add SEC adviser search, profile, and universe routes (#7559) * Add SEC institutional data fetchers * Add SEC filing index and Form D routes * Add SEC investment adviser people data * Expose SEC adviser and institutional routes * fix(sec): address adviser review feedback * refactor(sec): narrow adviser support to IAPD search * feat(sec): add adviser firm and individual searches * feat(sec): add adviser profile drill-down * fix: repair pre-commit configuration * feat(sec): add adviser universe route * feat(sec): expose complete Form ADV data --------- Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com> * fix test coverage --------- Co-authored-by: deeleeramone <> Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
329 lines
11 KiB
Python
329 lines
11 KiB
Python
"""Unit tests for the unified SEC disk cache."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from openbb_sec.utils import cache as cache_module
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_cache(tmp_path, monkeypatch):
|
|
"""Provide an isolated cache rooted in a temp directory.
|
|
|
|
Forces a fresh process-wide singleton bound to ``tmp_path`` and restores the
|
|
previous one on teardown so the global state does not leak across tests.
|
|
"""
|
|
monkeypatch.setattr(
|
|
cache_module, "get_cache_directory", lambda: str(tmp_path / "sec")
|
|
)
|
|
monkeypatch.delenv(cache_module.SIZE_LIMIT_ENV_VAR, raising=False)
|
|
previous = cache_module._cache
|
|
cache_module._cache = None
|
|
yield cache_module
|
|
if cache_module._cache is not None:
|
|
with contextlib.suppress(Exception):
|
|
cache_module._cache.close()
|
|
cache_module._cache = previous
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value, expected",
|
|
[
|
|
(None, cache_module.DEFAULT_SIZE_LIMIT),
|
|
("", cache_module.DEFAULT_SIZE_LIMIT),
|
|
(4096, 4096),
|
|
(0, cache_module.DEFAULT_SIZE_LIMIT),
|
|
(-10, cache_module.DEFAULT_SIZE_LIMIT),
|
|
("1024B", 1024),
|
|
("100KB", 100 * 1024),
|
|
("512MB", 512 * 1024**2),
|
|
("8GB", 8 * 1024**3),
|
|
("1.5TB", int(1.5 * 1024**4)),
|
|
("2 gb", 2 * 1024**3),
|
|
("12345", 12345),
|
|
("badGB", cache_module.DEFAULT_SIZE_LIMIT),
|
|
("notanumber", cache_module.DEFAULT_SIZE_LIMIT),
|
|
],
|
|
)
|
|
def test_parse_size(value, expected):
|
|
"""_parse_size handles bytes, suffixed strings, and bad input."""
|
|
assert cache_module._parse_size(value) == expected
|
|
|
|
|
|
def test_get_size_limit_env(monkeypatch):
|
|
"""get_size_limit reads the override env var, defaulting when unset."""
|
|
monkeypatch.delenv(cache_module.SIZE_LIMIT_ENV_VAR, raising=False)
|
|
assert cache_module.get_size_limit() == cache_module.DEFAULT_SIZE_LIMIT
|
|
monkeypatch.setenv(cache_module.SIZE_LIMIT_ENV_VAR, "256MB")
|
|
assert cache_module.get_size_limit() == 256 * 1024**2
|
|
|
|
|
|
def test_get_cache_directory(monkeypatch):
|
|
"""get_cache_directory points at a 'sec' subdirectory."""
|
|
monkeypatch.delenv(cache_module.CACHE_DIR_ENV_VAR, raising=False)
|
|
assert cache_module.get_cache_directory().replace("\\", "/").endswith("/sec")
|
|
|
|
|
|
def test_get_cache_directory_env_override(monkeypatch, tmp_path):
|
|
"""get_cache_directory honors the OPENBB_SEC_CACHE_DIR override."""
|
|
target = tmp_path / "custom_sec_cache"
|
|
monkeypatch.setenv(cache_module.CACHE_DIR_ENV_VAR, str(target))
|
|
assert cache_module.get_cache_directory() == os.path.abspath(str(target))
|
|
|
|
|
|
def test_get_cache_is_singleton(temp_cache):
|
|
"""get_cache builds the cache once and reuses it."""
|
|
first = temp_cache.get_cache()
|
|
second = temp_cache.get_cache()
|
|
assert first is second
|
|
assert temp_cache.get_size_limit() == temp_cache.DEFAULT_SIZE_LIMIT
|
|
|
|
|
|
def test_make_key():
|
|
"""_make_key encodes method, url, and suffix."""
|
|
assert cache_module._make_key("http://x") == "GET http://x"
|
|
assert cache_module._make_key("http://x", "post") == "POST http://x"
|
|
assert cache_module._make_key("http://x", suffix=" ::text") == "GET http://x ::text"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value, empty",
|
|
[
|
|
(None, True),
|
|
({}, True),
|
|
([], True),
|
|
({"a": 1}, False),
|
|
([1], False),
|
|
("text", False),
|
|
(0, False),
|
|
],
|
|
)
|
|
def test_is_empty(value, empty):
|
|
"""_is_empty flags None and empty containers only."""
|
|
assert cache_module._is_empty(value) is empty
|
|
|
|
|
|
def test_cache_set_get_roundtrip(temp_cache):
|
|
"""Values written can be read back; misses return None."""
|
|
temp_cache._cache_set("k", {"a": 1}, None)
|
|
assert temp_cache._cache_get("k") == {"a": 1}
|
|
assert temp_cache._cache_get("missing") is None
|
|
|
|
|
|
def test_cache_get_swallows_backend_errors(temp_cache, monkeypatch):
|
|
"""A backend failure on read degrades to a miss, never raises."""
|
|
|
|
class _Boom:
|
|
def get(self, *a, **k):
|
|
raise RuntimeError("backend down")
|
|
|
|
def set(self, *a, **k):
|
|
raise RuntimeError("backend down")
|
|
|
|
monkeypatch.setattr(temp_cache, "get_cache", _Boom)
|
|
assert temp_cache._cache_get("k") is None
|
|
temp_cache._cache_set("k", "v", None)
|
|
|
|
|
|
def _patch_amake(monkeypatch, return_value, counter):
|
|
async def _fake(url, method="GET", **kwargs):
|
|
counter.append((url, method))
|
|
return return_value
|
|
|
|
monkeypatch.setattr(
|
|
"openbb_core.provider.utils.helpers.amake_request", _fake, raising=False
|
|
)
|
|
|
|
|
|
def test_cached_request_bypass(temp_cache, monkeypatch):
|
|
"""use_cache=False calls the transport directly and never caches."""
|
|
calls: list = []
|
|
_patch_amake(monkeypatch, {"ok": 1}, calls)
|
|
result = asyncio.run(temp_cache.cached_request("http://x", use_cache=False))
|
|
assert result == {"ok": 1}
|
|
assert len(calls) == 1
|
|
assert temp_cache._cache_get(temp_cache._make_key("http://x")) is None
|
|
|
|
|
|
def test_cached_request_miss_then_hit(temp_cache, monkeypatch):
|
|
"""First call hits the transport and stores; second is served from cache."""
|
|
calls: list = []
|
|
_patch_amake(monkeypatch, {"ok": 1}, calls)
|
|
first = asyncio.run(temp_cache.cached_request("http://x"))
|
|
second = asyncio.run(temp_cache.cached_request("http://x"))
|
|
assert first == second == {"ok": 1}
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_cached_request_empty_not_stored(temp_cache, monkeypatch):
|
|
"""Empty responses are returned but not cached."""
|
|
calls: list = []
|
|
_patch_amake(monkeypatch, {}, calls)
|
|
asyncio.run(temp_cache.cached_request("http://x"))
|
|
assert temp_cache._cache_get(temp_cache._make_key("http://x")) is None
|
|
|
|
|
|
def test_cached_request_post(temp_cache, monkeypatch):
|
|
"""POST requests forward the method to the transport."""
|
|
calls: list = []
|
|
_patch_amake(monkeypatch, [1, 2], calls)
|
|
asyncio.run(temp_cache.cached_request("http://x", method="POST"))
|
|
assert calls == [("http://x", "POST")]
|
|
|
|
|
|
class _FakeAsyncResponse:
|
|
def __init__(self, status=200, json_data=None, text_data="", headers=None):
|
|
self.status = status
|
|
self._json = json_data
|
|
self._text = text_data
|
|
self.headers = headers or {}
|
|
|
|
async def json(self):
|
|
return self._json
|
|
|
|
async def text(self, encoding=None):
|
|
return self._text
|
|
|
|
|
|
def _patch_amake_callback(monkeypatch, response):
|
|
"""Mock transport that invokes the response callback, like the real one."""
|
|
|
|
async def _fake(url, method="GET", response_callback=None, **kwargs):
|
|
return await response_callback(response, None)
|
|
|
|
monkeypatch.setattr(
|
|
"openbb_core.provider.utils.helpers.amake_request", _fake, raising=False
|
|
)
|
|
|
|
|
|
def test_cached_request_default_callback_success(temp_cache, monkeypatch):
|
|
"""A 2xx response with no caller callback is parsed as JSON and cached."""
|
|
_patch_amake_callback(monkeypatch, _FakeAsyncResponse(200, {"ok": 1}))
|
|
result = asyncio.run(temp_cache.cached_request("http://x"))
|
|
assert result == {"ok": 1}
|
|
assert temp_cache._cache_get(temp_cache._make_key("http://x")) == {"ok": 1}
|
|
|
|
|
|
def test_cached_request_user_callback(temp_cache, monkeypatch):
|
|
"""A caller-supplied callback is used for a successful response."""
|
|
_patch_amake_callback(monkeypatch, _FakeAsyncResponse(200, {"v": 2}))
|
|
|
|
async def callback(response, _session):
|
|
return await response.json()
|
|
|
|
result = asyncio.run(
|
|
temp_cache.cached_request("http://x", response_callback=callback)
|
|
)
|
|
assert result == {"v": 2}
|
|
|
|
|
|
def test_cached_request_failed_not_cached(temp_cache, monkeypatch):
|
|
"""A failed (non-2xx) response raises and is never cached."""
|
|
from openbb_core.app.model.abstract.error import OpenBBError
|
|
|
|
_patch_amake_callback(monkeypatch, _FakeAsyncResponse(503))
|
|
with pytest.raises(OpenBBError):
|
|
asyncio.run(temp_cache.cached_request("http://x"))
|
|
assert temp_cache._cache_get(temp_cache._make_key("http://x")) is None
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, text="body", content=b"body", status_ok=True):
|
|
self.text = text
|
|
self.content = content
|
|
self._status_ok = status_ok
|
|
|
|
def raise_for_status(self):
|
|
if not self._status_ok:
|
|
raise RuntimeError("HTTP error")
|
|
|
|
|
|
def _patch_make(monkeypatch, response, counter):
|
|
def _fake(url, **kwargs):
|
|
counter.append(url)
|
|
return response
|
|
|
|
monkeypatch.setattr(
|
|
"openbb_core.provider.utils.helpers.make_request", _fake, raising=False
|
|
)
|
|
|
|
|
|
def test_cached_text_miss_then_hit(temp_cache, monkeypatch):
|
|
"""cached_text caches the body and serves the second call from cache."""
|
|
calls: list = []
|
|
_patch_make(monkeypatch, _FakeResponse(text="hello"), calls)
|
|
assert temp_cache.cached_text("http://x") == "hello"
|
|
assert temp_cache.cached_text("http://x") == "hello"
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_cached_text_bypass(temp_cache, monkeypatch):
|
|
"""use_cache=False always calls the transport."""
|
|
calls: list = []
|
|
_patch_make(monkeypatch, _FakeResponse(text="hi"), calls)
|
|
temp_cache.cached_text("http://x", use_cache=False)
|
|
temp_cache.cached_text("http://x", use_cache=False)
|
|
assert len(calls) == 2
|
|
|
|
|
|
def test_cached_text_raise_for_status(temp_cache, monkeypatch):
|
|
"""A non-2xx response raises when raise_for_status is True."""
|
|
_patch_make(monkeypatch, _FakeResponse(status_ok=False), [])
|
|
with pytest.raises(RuntimeError):
|
|
temp_cache.cached_text("http://x")
|
|
|
|
|
|
def test_cached_text_empty_not_stored(temp_cache, monkeypatch):
|
|
"""Empty bodies are returned but not cached."""
|
|
_patch_make(monkeypatch, _FakeResponse(text=""), [])
|
|
assert temp_cache.cached_text("http://x") == ""
|
|
assert (
|
|
temp_cache._cache_get(temp_cache._make_key("http://x", suffix=" ::text"))
|
|
is None
|
|
)
|
|
|
|
|
|
def test_cached_bytes_miss_then_hit(temp_cache, monkeypatch):
|
|
"""cached_bytes caches binary content."""
|
|
calls: list = []
|
|
_patch_make(monkeypatch, _FakeResponse(content=b"\x00\x01"), calls)
|
|
assert temp_cache.cached_bytes("http://x") == b"\x00\x01"
|
|
assert temp_cache.cached_bytes("http://x") == b"\x00\x01"
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_cached_bytes_no_raise_for_status(temp_cache, monkeypatch):
|
|
"""raise_for_status=False returns the body even on an error response."""
|
|
_patch_make(monkeypatch, _FakeResponse(content=b"x", status_ok=False), [])
|
|
assert temp_cache.cached_bytes("http://x", raise_for_status=False) == b"x"
|
|
|
|
|
|
def test_aget_aset_roundtrip(temp_cache):
|
|
"""Async key-value helpers round-trip through the cache."""
|
|
asyncio.run(temp_cache.aset_cached("k", [1, 2, 3]))
|
|
assert asyncio.run(temp_cache.aget_cached("k")) == [1, 2, 3]
|
|
assert asyncio.run(temp_cache.aget_cached("missing")) is None
|
|
|
|
|
|
def test_clear_cache(temp_cache):
|
|
"""clear_cache removes all entries and reports the count."""
|
|
temp_cache._cache_set("a", 1, None)
|
|
temp_cache._cache_set("b", 2, None)
|
|
removed = temp_cache.clear_cache()
|
|
assert removed >= 2
|
|
assert temp_cache._cache_get("a") is None
|
|
|
|
|
|
def test_clear_cache_error_returns_zero(temp_cache, monkeypatch):
|
|
"""clear_cache returns 0 when the backend errors."""
|
|
|
|
class _Boom:
|
|
def clear(self, *a, **k):
|
|
raise RuntimeError("backend down")
|
|
|
|
monkeypatch.setattr(temp_cache, "get_cache", _Boom)
|
|
assert temp_cache.clear_cache() == 0
|