Skip to content

Commit 80e7cbd

Browse files
authored
Merge pull request #2805 from dokson/fix/download-thread-safety
Make yf.download() reentrant by removing shared module globals
2 parents 3cfd9c0 + a710ba8 commit 80e7cbd

4 files changed

Lines changed: 166 additions & 122 deletions

File tree

‎tests/test_download_concurrency.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Concurrent ``yf.download()`` calls must not share scratch state.
2+
3+
These tests assert that two concurrent ``download()`` calls each receive a
4+
DataFrame containing exactly the tickers they requested — no contamination,
5+
no exceptions — and that ``download()`` no longer relies on module-level
6+
dicts in ``yfinance.shared`` as scratch space.
7+
"""
8+
import unittest
9+
from concurrent.futures import ThreadPoolExecutor
10+
11+
from tests.context import yfinance as yf
12+
13+
14+
class TestDownloadConcurrency(unittest.TestCase):
15+
16+
def _fetch(self, tickers):
17+
return yf.download(
18+
tickers, period="5d", interval="1d",
19+
threads=False, progress=False, auto_adjust=False,
20+
)
21+
22+
def test_concurrent_downloads_keep_results_separate(self):
23+
chunk_a = ["AAPL", "MSFT", "GOOG"]
24+
chunk_b = ["NVDA", "META", "AMZN"]
25+
26+
with ThreadPoolExecutor(max_workers=2) as ex:
27+
futures = [ex.submit(self._fetch, chunk_a), ex.submit(self._fetch, chunk_b)]
28+
results = [f.result() for f in futures]
29+
30+
# Each result must contain exactly its requested tickers — neither
31+
# missing any nor leaking the other call's tickers.
32+
got_a = set(results[0].columns.get_level_values("Ticker"))
33+
got_b = set(results[1].columns.get_level_values("Ticker"))
34+
self.assertEqual(got_a, set(chunk_a))
35+
self.assertEqual(got_b, set(chunk_b))
36+
37+
def test_concurrent_downloads_do_not_raise(self):
38+
# Repeat several times to make races more likely to surface.
39+
chunks = [["AAPL", "MSFT"], ["NVDA", "META"], ["GOOG", "AMZN"], ["TSLA", "JPM"]]
40+
with ThreadPoolExecutor(max_workers=4) as ex:
41+
list(ex.map(self._fetch, chunks))
42+
43+
def test_download_does_not_use_module_globals(self):
44+
# Pre-populate shared._DFS with a sentinel; if download() still
45+
# depended on it as scratch space, the sentinel would either be
46+
# wiped or leak into the result.
47+
from yfinance import shared
48+
sentinel = object()
49+
shared._DFS = {"__SENTINEL__": sentinel}
50+
51+
df = self._fetch(["AAPL"])
52+
self.assertEqual(set(df.columns.get_level_values("Ticker")), {"AAPL"})
53+
self.assertIs(shared._DFS.get("__SENTINEL__"), sentinel)
54+
55+
56+
if __name__ == "__main__":
57+
unittest.main()

‎tests/test_multi.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import pandas as pd
77

88
from tests.context import yfinance as yf
9-
from yfinance import shared
109

1110

1211
class TestDownloadThreadSafety(unittest.TestCase):
@@ -23,10 +22,12 @@ def test_concurrent_downloads_return_only_own_tickers(self):
2322
index=idx,
2423
)
2524

26-
def mock_download_one(ticker, *args, **kwargs):
25+
def mock_download_one(ctx, ticker, *args, **kwargs):
2726
time.sleep(0.05)
28-
df = aapl_df if ticker.upper() == 'AAPL' else msft_df
29-
shared._DFS[ticker.upper()] = df
27+
sym = ticker.upper()
28+
df = aapl_df if sym == 'AAPL' else msft_df
29+
with ctx.lock:
30+
ctx.dfs[sym] = df
3031
return df
3132

3233
results = {}

0 commit comments

Comments
 (0)