Files
yfinance-fork/tests/test_multi.py
Alessandro Colace a710ba8340 Make yf.download() reentrant by removing shared module globals
`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.
2026-05-09 18:47:53 +02:00

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()