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.
64 lines
2.0 KiB
Python
64 lines
2.0 KiB
Python
import threading
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import pandas as pd
|
|
|
|
from tests.context import yfinance as yf
|
|
|
|
|
|
class TestDownloadThreadSafety(unittest.TestCase):
|
|
|
|
def test_concurrent_downloads_return_only_own_tickers(self):
|
|
"""Concurrent download() calls must not mix results via shared state."""
|
|
idx = pd.DatetimeIndex(['2024-01-02', '2024-01-03'], tz='America/New_York')
|
|
aapl_df = pd.DataFrame(
|
|
{'Open': [185.0, 186.0], 'Close': [185.5, 186.5]},
|
|
index=idx,
|
|
)
|
|
msft_df = pd.DataFrame(
|
|
{'Open': [375.0, 376.0], 'Close': [375.5, 376.5]},
|
|
index=idx,
|
|
)
|
|
|
|
def mock_download_one(ctx, ticker, *args, **kwargs):
|
|
time.sleep(0.05)
|
|
sym = ticker.upper()
|
|
df = aapl_df if sym == 'AAPL' else msft_df
|
|
with ctx.lock:
|
|
ctx.dfs[sym] = df
|
|
return df
|
|
|
|
results = {}
|
|
errors = {}
|
|
|
|
def do_download(tickers, key):
|
|
try:
|
|
results[key] = yf.download(
|
|
tickers, threads=False, progress=False,
|
|
)
|
|
except Exception as e:
|
|
errors[key] = e
|
|
|
|
with patch('yfinance.multi._download_one', side_effect=mock_download_one), \
|
|
patch('yfinance.multi.YfData'):
|
|
t1 = threading.Thread(target=do_download, args=(['AAPL'], 'aapl'))
|
|
t2 = threading.Thread(target=do_download, args=(['MSFT'], 'msft'))
|
|
t1.start()
|
|
t2.start()
|
|
t1.join(timeout=30)
|
|
t2.join(timeout=30)
|
|
|
|
self.assertFalse(errors, f"Download raised: {errors}")
|
|
|
|
aapl_tickers = results['aapl'].columns.get_level_values('Ticker').unique().tolist()
|
|
msft_tickers = results['msft'].columns.get_level_values('Ticker').unique().tolist()
|
|
|
|
self.assertEqual(aapl_tickers, ['AAPL'])
|
|
self.assertEqual(msft_tickers, ['MSFT'])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|