Merge branch 'dev' into fix/download-tz

This commit is contained in:
ValueRaider
2026-05-20 21:44:17 +01:00
committed by GitHub
26 changed files with 329 additions and 39 deletions
-6
View File
@@ -1,12 +1,6 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
- package-ecosystem: "github-actions"
directory: "/"
schedule:
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Check if issue uses custom template
# Verify: curl -s "https://api.github.com/repos/actions/github-script/commits?sha=v8&until=$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ')&per_page=1" | jq -r '.[0].sha'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const issue = context.payload.issue;
+1 -1
View File
@@ -16,6 +16,6 @@ jobs:
with:
persist-credentials: false
# Verify: curl -s "https://api.github.com/repos/astral-sh/ruff-action/commits?sha=v3&until=$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ')&per_page=1" | jq -r '.[0].sha'
- uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3
- uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
with:
args: check --ignore E702 . --exclude yfinance/pricing_pb2.py
+1 -1
View File
@@ -20,6 +20,6 @@ jobs:
persist-credentials: false
- name: Run zizmor
# Verify: curl -s "https://api.github.com/repos/zizmorcore/zizmor-action/commits?sha=v0.5.2&until=$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ')&per_page=1" | jq -r '.[0].sha'
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+21
View File
@@ -0,0 +1,21 @@
# Changelog
## Unreleased
- Merge pull request #2802 from dokson/feature/optional-curl-cffi ([#2802]()) (9ca1c55)
- Make curl_cffi optional with graceful fallback to requests (5d10c0c)
- Merge pull request #2827 from ranaroussi/main ([#2827]()) (628a417)
- Merge pull request #2821 from dokson/feature/drop-frozendict-dep ([#2821]()) (0c36060)
- Drop frozendict hard dependency in favour of an internal fallback (593b47c)
- Merge pull request #2752 from ranaroussi/dependabot/github_actions/astral-sh/ruff-action-4.0.0 ([#2752]()) (a766e8f)
- Merge pull request #2754 from ranaroussi/dependabot/github_actions/zizmorcore/zizmor-action-0.5.3 ([#2754]()) (75ed996)
- Merge pull request #2753 from ranaroussi/dependabot/github_actions/actions/github-script-9.0.0 ([#2753]()) (803893a)
- Merge pull request #2789 from gabrielchangamire-arch/fix-market-api-docs-reference-link ([#2789]()) (a497d4f)
- Fix Market API docs reference link (38ad379)
- Dependabot: disable no-reason version updates (4c544ca)
- Version 1.3.0 (a17e8b1)
- Merge pull request #2757 from ranaroussi/dev ([#2757]()) (d6afeeb)
- Bump zizmorcore/zizmor-action from 0.5.2 to 0.5.3 (6cebd23)
- Bump actions/github-script from 8.0.0 to 9.0.0 (a07bf32)
- Bump astral-sh/ruff-action from 3.6.1 to 4.0.0 (cb9884c)
+7
View File
@@ -1,6 +1,13 @@
Change Log
===========
1.3.0
-----
Features:
- Add Valuations Measures Table from Statistics Page #2742
- Add ETFQuery #2749
Fix type regresssion in Ticker.dividends
1.2.2
-----
Forgot to merge in #2695 and #2718 for release 1.2.1
+2
View File
@@ -53,6 +53,8 @@ Install `yfinance` from PYPI using `pip`:
$ pip install yfinance
```
To install without `curl_cffi` for requests fallback, see [Advanced ▸ Installation](https://ranaroussi.github.io/yfinance/advanced/install.html).
### [yfinance relies on the community to investigate bugs and contribute code. Here's how you can help.](https://github.com/ranaroussi/yfinance/blob/main/CONTRIBUTING.md)
---
+1
View File
@@ -5,6 +5,7 @@ Advanced
.. toctree::
:maxdepth: 2
install
logging
config
caching
+9
View File
@@ -0,0 +1,9 @@
Installation
============
To install without ``curl_cffi`` for requests fallback:
.. code-block:: bash
curl -fsSL https://raw.githubusercontent.com/ranaroussi/yfinance/main/requirements.txt | grep -vi '^curl_cffi' | pip install -r /dev/stdin
pip install --no-deps yfinance
+1 -1
View File
@@ -15,7 +15,7 @@ The following are the publicly available classes, and functions exposed by the `
- :attr:`Ticker <yfinance.Ticker>`: Class for accessing single ticker data.
- :attr:`Tickers <yfinance.Tickers>`: Class for handling multiple tickers.
- :attr:`Market <yfinance.Market>`: Class for accessing market summary.
- :doc:`Market <yfinance.market>`: Class for accessing market summary.
- :attr:`Calendars <yfinance.Calendars>`: Class for accessing calendar events data.
- :attr:`download <yfinance.download>`: Function to download market data for multiple tickers.
- :attr:`Search <yfinance.Search>`: Class for accessing search results.
+1 -1
View File
@@ -1,5 +1,5 @@
{% set name = "yfinance" %}
{% set version = "1.2.2" %}
{% set version = "1.3.0" %}
package:
name: "{{ name|lower }}"
-1
View File
@@ -4,7 +4,6 @@ requests>=2.31
multitasking>=0.0.7
platformdirs>=2.0.0
pytz>=2022.5
frozendict>=2.3.4
beautifulsoup4>=4.11.1
lxml>=4.9.0
peewee>=3.16.2
+1 -1
View File
@@ -62,7 +62,7 @@ setup(
install_requires=['pandas>=1.3.0', 'numpy>=1.16.5',
'requests>=2.31', 'multitasking>=0.0.7',
'platformdirs>=2.0.0', 'pytz>=2022.5',
'frozendict>=2.3.4', 'peewee>=3.16.2',
'peewee>=3.16.2',
'beautifulsoup4>=4.11.1', 'curl_cffi>=0.15',
'protobuf>=3.19.0', 'websockets>=13.0'],
extras_require={
+53
View File
@@ -0,0 +1,53 @@
"""Tests for yfinance.data internal helpers."""
import unittest
from functools import lru_cache
from yfinance.data import lru_cache_freezeargs
from yfinance.utils import frozendict
class TestFrozenDict(unittest.TestCase):
def test_is_dict_subclass(self):
d = frozendict({"a": 1, "b": 2})
self.assertEqual(d["a"], 1)
self.assertEqual(d.get("b"), 2)
self.assertEqual(set(d.keys()), {"a", "b"})
self.assertIsInstance(d, dict)
def test_is_hashable(self):
d1 = frozendict({"a": 1, "b": 2})
d2 = frozendict({"b": 2, "a": 1})
self.assertEqual(hash(d1), hash(d2))
self.assertEqual(len({d1, d2}), 1)
def test_mutation_raises(self):
d = frozendict({"a": 1})
with self.assertRaises(TypeError):
d["b"] = 2
with self.assertRaises(TypeError):
del d["a"]
with self.assertRaises(AttributeError):
d.pop("a")
with self.assertRaises(AttributeError):
d.clear()
with self.assertRaises(AttributeError):
d.update({"x": 9})
def test_lru_cache_integration(self):
call_count = {"n": 0}
@lru_cache_freezeargs
@lru_cache(maxsize=8)
def fn(params):
call_count["n"] += 1
return sum(params.values())
self.assertEqual(fn({"a": 1, "b": 2}), 3)
self.assertEqual(fn({"a": 1, "b": 2}), 3)
self.assertEqual(call_count["n"], 1)
self.assertEqual(fn({"a": 1, "b": 3}), 4)
self.assertEqual(call_count["n"], 2)
if __name__ == "__main__":
unittest.main()
+75
View File
@@ -0,0 +1,75 @@
"""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()
+101
View File
@@ -0,0 +1,101 @@
"""HTTP backend abstraction.
Prefers ``curl_cffi`` for browser TLS impersonation. Falls back to plain
``requests`` with a realistic ``User-Agent`` when ``curl_cffi`` cannot be
imported (e.g. binary not buildable on the host platform — see issue #2692)
or when ``YF_DISABLE_CURL_CFFI`` is set in the environment (downstream
packagers may ship without ``curl_cffi`` even if it is installed).
The fallback is best-effort: plain ``requests`` cannot replicate the
JA3/JA4 fingerprint and HTTP/2 settings that ``curl_cffi`` provides, so
Yahoo Finance may rate-limit or block this client. ``curl_cffi`` remains
the preferred backend and the default install dependency.
"""
import functools
import os
from . import utils
_DISABLE = os.environ.get("YF_DISABLE_CURL_CFFI", "").lower() in ("1", "true", "yes")
if not _DISABLE:
try:
from curl_cffi import requests as _curl_backend
_backend = _curl_backend
HAS_CURL_CFFI = True
except ImportError:
import requests as _requests_backend
_backend = _requests_backend
HAS_CURL_CFFI = False
else:
import requests as _requests_backend
_backend = _requests_backend
HAS_CURL_CFFI = False
requests = _backend
HTTPError = _backend.exceptions.HTTPError
_FALLBACK_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
_fallback_warned = False
def _warn_once_on_fallback():
global _fallback_warned
if HAS_CURL_CFFI or _fallback_warned:
return
_fallback_warned = True
utils.get_yf_logger().warning(
"curl_cffi not available; falling back to requests without browser TLS "
"impersonation. Yahoo Finance may rate-limit or block this client. "
"Install curl_cffi (>=0.15) for the supported configuration."
)
def new_session():
"""Create a default Session for the active backend."""
if HAS_CURL_CFFI:
return _backend.Session(impersonate="chrome")
_warn_once_on_fallback()
s = _backend.Session()
s.headers.update({
"User-Agent": _FALLBACK_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
})
return s
def cookie_jar(session):
"""Return the underlying ``http.cookiejar.CookieJar`` for either backend.
``curl_cffi`` exposes the jar as ``session.cookies.jar``; ``requests``
uses ``session.cookies`` directly (it subclasses ``CookieJar``).
"""
cookies = session.cookies
return getattr(cookies, "jar", cookies)
@functools.lru_cache(maxsize=1)
def _supported_session_classes() -> tuple:
classes = []
try:
from curl_cffi.requests.session import Session as _CurlSession
classes.append(_CurlSession)
except ImportError:
pass
try:
from requests.sessions import Session as _ReqSession
classes.append(_ReqSession)
except ImportError:
pass
return tuple(classes)
def is_supported_session(obj) -> bool:
"""True if ``obj`` is a Session from either supported backend."""
classes = _supported_session_classes()
return bool(classes) and isinstance(obj, classes)
+2 -2
View File
@@ -27,7 +27,7 @@ from urllib.parse import quote as urlencode
import numpy as np
import pandas as pd
from curl_cffi import requests
from ._http import requests, new_session
from . import utils, cache
@@ -81,7 +81,7 @@ class TickerBase:
ticker = base_symbol
self.ticker = ticker.upper()
self.session = session or requests.Session(impersonate="chrome")
self.session = session or new_session()
self._tz = None
self._isin = None
+10 -10
View File
@@ -4,14 +4,13 @@ import socket
import time as _time
import json as _json
from curl_cffi import requests
from ._http import requests, new_session, is_supported_session, cookie_jar
from urllib.parse import urlsplit, urljoin
from bs4 import BeautifulSoup
import datetime
from frozendict import frozendict
from . import utils, cache
from .utils import frozendict
from .config import YfConfig
import threading
@@ -91,7 +90,7 @@ class YfData(metaclass=SingletonMeta):
self._cookie_lock = threading.Lock()
self._session = None
self._set_session(session or requests.Session(impersonate="chrome"))
self._set_session(session or new_session())
def set_login_cookies(self, cookie_t, cookie_y):
with self._cookie_lock:
@@ -115,11 +114,12 @@ class YfData(metaclass=SingletonMeta):
# Can't simply use a non-caching session to fetch cookie & crumb,
# because then the caching-session won't have cookie.
self._session_is_caching = True
# But since switch to curl_cffi, can't use requests_cache with it.
raise YFDataException("request_cache sessions don't work with curl_cffi, which is necessary now for Yahoo API. Solution: stop setting session, let YF handle.")
# requests_cache wraps the stdlib Session; it doesn't work with
# curl_cffi and tends to miss anyway because the Yahoo crumb rotates.
raise YFDataException("Caching sessions (e.g. requests_cache) are not supported. Solution: stop setting session, let yfinance handle.")
if not isinstance(session, requests.session.Session):
raise YFDataException(f"Yahoo API requires curl_cffi session not {type(session)}. Solution: stop setting session, let YF handle.")
if not is_supported_session(session):
raise YFDataException(f"Unsupported session type {type(session)}; expected curl_cffi or requests Session. Solution: stop setting session, let yfinance handle.")
with self._cookie_lock:
self._session = session
@@ -153,7 +153,7 @@ class YfData(metaclass=SingletonMeta):
def _save_cookie_curlCffi(self):
if self._session is None:
return False
cookies = self._session.cookies.jar._cookies
cookies = cookie_jar(self._session)._cookies
if len(cookies) == 0:
return False
yh_domains = [k for k in cookies.keys() if 'yahoo' in k]
@@ -189,7 +189,7 @@ class YfData(metaclass=SingletonMeta):
if expired:
utils.get_yf_logger().debug('cached cookie expired')
return False
self._session.cookies.jar._cookies.update(cookies)
cookie_jar(self._session)._cookies.update(cookies)
self._cookie = cookie
return True
+2 -2
View File
@@ -30,7 +30,7 @@ from typing import Union
import multitasking as _multitasking
import pandas as _pd
import numpy as _np
from curl_cffi import requests
from ._http import new_session
from . import Ticker, utils
from .data import YfData
@@ -123,7 +123,7 @@ def _download_impl(ctx, tickers, start=None, end=None, actions=False, threads=Tr
prepost=False, rounding=False, timeout=10, session=None,
multi_level_index=True):
logger = utils.get_yf_logger()
session = session or requests.Session(impersonate="chrome")
session = session or new_session()
YfData(session=session)
+2 -2
View File
@@ -1,4 +1,4 @@
import curl_cffi
from yfinance._http import HTTPError
import pandas as pd
from yfinance import utils
@@ -187,7 +187,7 @@ class Analysis:
params_dict = {"modules": modules, "corsDomain": "finance.yahoo.com", "formatted": "false", "symbol": self._symbol}
try:
result = self._data.get_raw_json(_QUOTE_SUMMARY_URL_ + f"/{self._symbol}", params=params_dict)
except curl_cffi.requests.exceptions.HTTPError as e:
except HTTPError as e:
if not YfConfig.debug.hide_exceptions:
raise
utils.get_yf_logger().error(str(e) + e.response.text)
+2 -2
View File
@@ -1,4 +1,4 @@
from curl_cffi import requests
from yfinance._http import new_session
from math import isclose
import bisect
import datetime as _datetime
@@ -19,7 +19,7 @@ class PriceHistory:
self._data = data
self.ticker = ticker.upper()
self.tz = tz
self.session = session or requests.Session(impersonate="chrome")
self.session = session or new_session()
self._history_cache = {}
self._history_metadata = None
+2 -2
View File
@@ -1,4 +1,4 @@
import curl_cffi
from yfinance._http import HTTPError
import pandas as pd
from yfinance import utils
@@ -71,7 +71,7 @@ class Holders:
def _fetch_and_parse(self):
try:
result = self._fetch()
except curl_cffi.requests.exceptions.HTTPError as e:
except HTTPError as e:
if not YfConfig.debug.hide_exceptions:
raise
utils.get_yf_logger().error(str(e) + e.response.text)
+3 -3
View File
@@ -1,4 +1,4 @@
import curl_cffi
from yfinance._http import HTTPError
import datetime
import json
import numpy as _np
@@ -594,7 +594,7 @@ class Quote:
params_dict = {"modules": modules, "corsDomain": "finance.yahoo.com", "formatted": "false", "symbol": self._symbol, "lang": YfConfig.locale.lang, "region": YfConfig.locale.region}
try:
result = self._data.get_raw_json(_QUOTE_SUMMARY_URL_ + f"/{self._symbol}", params=params_dict)
except curl_cffi.requests.exceptions.HTTPError as e:
except HTTPError as e:
if not YfConfig.debug.hide_exceptions:
raise
utils.get_yf_logger().error(str(e) + e.response.text)
@@ -605,7 +605,7 @@ class Quote:
params_dict = {"symbols": self._symbol, "formatted": "false", "lang": YfConfig.locale.lang, "region": YfConfig.locale.region}
try:
result = self._data.get_raw_json(f"{_QUERY1_URL_}/v7/finance/quote?", params=params_dict)
except curl_cffi.requests.exceptions.HTTPError as e:
except HTTPError as e:
if not YfConfig.debug.hide_exceptions:
raise
utils.get_yf_logger().error(str(e) + e.response.text)
+2 -2
View File
@@ -1,4 +1,4 @@
import curl_cffi
from yfinance._http import HTTPError
from typing import Union
import warnings
from json import dumps
@@ -176,7 +176,7 @@ def screen(query: Union[str, EquityQuery, FundQuery, ETFQuery],
resp = _data.get(url=_PREDEFINED_URL_, params=params_dict)
try:
resp.raise_for_status()
except curl_cffi.requests.exceptions.HTTPError:
except HTTPError:
if query not in PREDEFINED_SCREENER_QUERIES:
print(f"yfinance.screen: '{query}' is probably not a predefined query.")
raise
+28
View File
@@ -43,6 +43,34 @@ from yfinance import const
from yfinance.exceptions import YFException
from yfinance.config import YfConfig
# Use the third-party ``frozendict`` package if installed; otherwise fall
# back to a small pure-Python equivalent (PEP 814).
try:
from frozendict import frozendict # type: ignore[import-not-found]
except ImportError:
class frozendict(dict): # type: ignore[no-redef]
"""Hashable, read-only ``dict`` used as an ``lru_cache`` key."""
__slots__ = ()
def __hash__(self): # type: ignore[override]
return hash(frozenset(self.items()))
def __setitem__(self, *args, **kwargs):
raise TypeError(f"'{type(self).__name__}' object doesn't support item assignment")
def __delitem__(self, *args, **kwargs):
raise TypeError(f"'{type(self).__name__}' object doesn't support item deletion")
def _readonly(self, *args, **kwargs):
raise AttributeError(f"'{type(self).__name__}' object is read-only")
pop = _readonly # type: ignore[assignment]
popitem = _readonly # type: ignore[assignment]
clear = _readonly # type: ignore[assignment]
update = _readonly # type: ignore[assignment]
setdefault = _readonly # type: ignore[assignment]
# From https://stackoverflow.com/a/59128615
def attributes(obj):
disallowed_names = {
+1 -1
View File
@@ -1 +1 @@
version = "1.2.2"
version = "1.3.0"