a710ba8340
`download()` used module-level dicts in `yfinance.shared` (`_DFS`, `_ERRORS`, `_TRACEBACKS`, `_ISINS`) as scratch space. Concurrent calls trampled each other; the previous fix wrapped the whole call in `shared._LOCK`, which silently serialized parallel downloads — hiding the race instead of removing it, and defeating the parallelism users reach for `download()` to get. - Introduce `_DownloadCtx` per call holding `dfs/errors/tracebacks/isins` plus a fine-grained lock for thread workers. - `_download_one`, `_download_one_threaded`, `_realign_dfs` take the ctx explicitly and write into it. - Drop the call-wide `shared._LOCK.acquire()`; concurrent `download()` calls now actually run in parallel. The polling loop on the threaded path reads `len(ctx.dfs)` under the ctx lock. - `PriceHistory` records soft errors (delisted, missing tz, repair failure) in `self._last_error` instead of `shared._ERRORS`; the download loop reads it after `Ticker.history()` returns. - `yfinance.shared` is left in place for import-compat with code that may still import it, but yfinance no longer reads or writes it. - Update existing test_multi to the new `_download_one(ctx, ...)` signature and add regression tests covering result separation, no-raise on concurrent calls, and a sentinel proving `shared._DFS` is no longer used as scratch space.
58 lines
2.2 KiB
Python
58 lines
2.2 KiB
Python
"""Concurrent ``yf.download()`` calls must not share scratch state.
|
|
|
|
These tests assert that two concurrent ``download()`` calls each receive a
|
|
DataFrame containing exactly the tickers they requested — no contamination,
|
|
no exceptions — and that ``download()`` no longer relies on module-level
|
|
dicts in ``yfinance.shared`` as scratch space.
|
|
"""
|
|
import unittest
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
from tests.context import yfinance as yf
|
|
|
|
|
|
class TestDownloadConcurrency(unittest.TestCase):
|
|
|
|
def _fetch(self, tickers):
|
|
return yf.download(
|
|
tickers, period="5d", interval="1d",
|
|
threads=False, progress=False, auto_adjust=False,
|
|
)
|
|
|
|
def test_concurrent_downloads_keep_results_separate(self):
|
|
chunk_a = ["AAPL", "MSFT", "GOOG"]
|
|
chunk_b = ["NVDA", "META", "AMZN"]
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as ex:
|
|
futures = [ex.submit(self._fetch, chunk_a), ex.submit(self._fetch, chunk_b)]
|
|
results = [f.result() for f in futures]
|
|
|
|
# Each result must contain exactly its requested tickers — neither
|
|
# missing any nor leaking the other call's tickers.
|
|
got_a = set(results[0].columns.get_level_values("Ticker"))
|
|
got_b = set(results[1].columns.get_level_values("Ticker"))
|
|
self.assertEqual(got_a, set(chunk_a))
|
|
self.assertEqual(got_b, set(chunk_b))
|
|
|
|
def test_concurrent_downloads_do_not_raise(self):
|
|
# Repeat several times to make races more likely to surface.
|
|
chunks = [["AAPL", "MSFT"], ["NVDA", "META"], ["GOOG", "AMZN"], ["TSLA", "JPM"]]
|
|
with ThreadPoolExecutor(max_workers=4) as ex:
|
|
list(ex.map(self._fetch, chunks))
|
|
|
|
def test_download_does_not_use_module_globals(self):
|
|
# Pre-populate shared._DFS with a sentinel; if download() still
|
|
# depended on it as scratch space, the sentinel would either be
|
|
# wiped or leak into the result.
|
|
from yfinance import shared
|
|
sentinel = object()
|
|
shared._DFS = {"__SENTINEL__": sentinel}
|
|
|
|
df = self._fetch(["AAPL"])
|
|
self.assertEqual(set(df.columns.get_level_values("Ticker")), {"AAPL"})
|
|
self.assertIs(shared._DFS.get("__SENTINEL__"), sentinel)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|