Files
yfinance-fork/tests/test_http.py
Alessandro Colace 5d10c0c48e Make curl_cffi optional with graceful fallback to requests
Some platforms (e.g. older macOS, exotic Linux distros) cannot build the
curl-impersonate binary that backs curl_cffi, leaving yfinance unusable.
This change keeps curl_cffi as the preferred and default backend but no
longer hard-requires it at runtime.

- New `yfinance/_http.py` abstracts the HTTP backend. If `curl_cffi` is
  importable it is used as before; otherwise yfinance falls back to plain
  `requests` with a realistic Chrome User-Agent and logs a warning so the
  downgrade is explicit.
- `data.py`, `base.py`, `multi.py`, `scrapers/history.py` build sessions
  via `_http.new_session()`.
- Scrapers and screener catch `_http.HTTPError` instead of importing
  `curl_cffi.requests.exceptions.HTTPError` directly.
- `is_supported_session()` accepts either backend; `cookie_jar()` papers
  over the small API difference between curl_cffi (`cookies.jar`) and
  requests (`cookies` is itself the jar).
- New Advanced > Installation page documents the curl_cffi-free install
  recipe; README links to it just under the install instruction.

Empirically verified that plain `requests` with a Chrome UA does not hit
the 401-after-~10-requests issue that the earlier `curl_adapter` approach
ran into; 15/15 quoteSummary calls succeed without rate limiting.

`setup.py` is unchanged -- `curl_cffi>=0.15` remains the default install
requirement (CVE-pinned). The fallback only activates when the import
fails at runtime; moving curl_cffi to extras_require can be a follow-up
decision for the maintainer.
2026-05-20 14:17:02 +02:00

76 lines
2.8 KiB
Python

"""Tests for the HTTP backend abstraction.
Verifies the fallback to plain ``requests`` when ``curl_cffi`` is
unavailable, and the ``YF_DISABLE_CURL_CFFI`` env-var opt-out.
"""
import importlib
import os
import sys
import unittest
def _reload_http(curl_cffi_available: bool, disable_env: bool):
"""Reload ``yfinance._http`` simulating curl_cffi presence / opt-out."""
saved_modules = {k: sys.modules[k] for k in list(sys.modules) if k == "yfinance._http"}
saved_env = os.environ.get("YF_DISABLE_CURL_CFFI")
if disable_env:
os.environ["YF_DISABLE_CURL_CFFI"] = "1"
elif "YF_DISABLE_CURL_CFFI" in os.environ:
del os.environ["YF_DISABLE_CURL_CFFI"]
saved_curl = sys.modules.get("curl_cffi")
if not curl_cffi_available:
sys.modules["curl_cffi"] = None # type: ignore[assignment]
try:
sys.modules.pop("yfinance._http", None)
return importlib.import_module("yfinance._http")
finally:
# Restore originals so other tests aren't affected.
if saved_curl is not None:
sys.modules["curl_cffi"] = saved_curl
elif not curl_cffi_available:
sys.modules.pop("curl_cffi", None)
if saved_env is None:
os.environ.pop("YF_DISABLE_CURL_CFFI", None)
else:
os.environ["YF_DISABLE_CURL_CFFI"] = saved_env
sys.modules.pop("yfinance._http", None)
for k, v in saved_modules.items():
sys.modules[k] = v
class TestHttpBackend(unittest.TestCase):
def test_fallback_when_curl_cffi_missing(self):
import requests as _stdlib_requests
mod = _reload_http(curl_cffi_available=False, disable_env=False)
self.assertFalse(mod.HAS_CURL_CFFI)
session = mod.new_session()
self.assertIsInstance(session, _stdlib_requests.Session)
self.assertIn("Chrome", session.headers["User-Agent"])
def test_disable_env_var_forces_fallback(self):
import requests as _stdlib_requests
mod = _reload_http(curl_cffi_available=True, disable_env=True)
self.assertFalse(mod.HAS_CURL_CFFI)
self.assertIsInstance(mod.new_session(), _stdlib_requests.Session)
def test_cookie_jar_works_for_requests_session(self):
mod = _reload_http(curl_cffi_available=False, disable_env=False)
session = mod.new_session()
jar = mod.cookie_jar(session)
# requests.Session.cookies subclasses CookieJar directly.
from http.cookiejar import CookieJar
self.assertIsInstance(jar, CookieJar)
def test_is_supported_session_accepts_requests_session(self):
import requests as _stdlib_requests
mod = _reload_http(curl_cffi_available=False, disable_env=False)
self.assertTrue(mod.is_supported_session(_stdlib_requests.Session()))
self.assertFalse(mod.is_supported_session(object()))
if __name__ == "__main__":
unittest.main()