|
| 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() |
0 commit comments