diff --git a/requirements.txt b/requirements.txt index b604a47..babd7ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ requests>=2.31 multitasking>=0.0.7 platformdirs>=2.0.0 pytz>=2022.5 -frozendict>=2.3.4 beautifulsoup4>=4.11.1 lxml>=4.9.0 peewee>=3.16.2 diff --git a/setup.py b/setup.py index cbe04b7..ab6bf36 100644 --- a/setup.py +++ b/setup.py @@ -62,7 +62,7 @@ setup( install_requires=['pandas>=1.3.0', 'numpy>=1.16.5', 'requests>=2.31', 'multitasking>=0.0.7', 'platformdirs>=2.0.0', 'pytz>=2022.5', - 'frozendict>=2.3.4', 'peewee>=3.16.2', + 'peewee>=3.16.2', 'beautifulsoup4>=4.11.1', 'curl_cffi>=0.15', 'protobuf>=3.19.0', 'websockets>=13.0'], extras_require={ diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..d0867e4 --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,53 @@ +"""Tests for yfinance.data internal helpers.""" +import unittest +from functools import lru_cache + +from yfinance.data import lru_cache_freezeargs +from yfinance.utils import frozendict + + +class TestFrozenDict(unittest.TestCase): + def test_is_dict_subclass(self): + d = frozendict({"a": 1, "b": 2}) + self.assertEqual(d["a"], 1) + self.assertEqual(d.get("b"), 2) + self.assertEqual(set(d.keys()), {"a", "b"}) + self.assertIsInstance(d, dict) + + def test_is_hashable(self): + d1 = frozendict({"a": 1, "b": 2}) + d2 = frozendict({"b": 2, "a": 1}) + self.assertEqual(hash(d1), hash(d2)) + self.assertEqual(len({d1, d2}), 1) + + def test_mutation_raises(self): + d = frozendict({"a": 1}) + with self.assertRaises(TypeError): + d["b"] = 2 + with self.assertRaises(TypeError): + del d["a"] + with self.assertRaises(AttributeError): + d.pop("a") + with self.assertRaises(AttributeError): + d.clear() + with self.assertRaises(AttributeError): + d.update({"x": 9}) + + def test_lru_cache_integration(self): + call_count = {"n": 0} + + @lru_cache_freezeargs + @lru_cache(maxsize=8) + def fn(params): + call_count["n"] += 1 + return sum(params.values()) + + self.assertEqual(fn({"a": 1, "b": 2}), 3) + self.assertEqual(fn({"a": 1, "b": 2}), 3) + self.assertEqual(call_count["n"], 1) + self.assertEqual(fn({"a": 1, "b": 3}), 4) + self.assertEqual(call_count["n"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/yfinance/data.py b/yfinance/data.py index 5f2a9ba..693084b 100644 --- a/yfinance/data.py +++ b/yfinance/data.py @@ -9,9 +9,8 @@ from urllib.parse import urlsplit, urljoin from bs4 import BeautifulSoup import datetime -from frozendict import frozendict - from . import utils, cache +from .utils import frozendict from .config import YfConfig import threading diff --git a/yfinance/utils.py b/yfinance/utils.py index 3db289f..99de06d 100644 --- a/yfinance/utils.py +++ b/yfinance/utils.py @@ -43,6 +43,34 @@ from yfinance import const from yfinance.exceptions import YFException from yfinance.config import YfConfig +# Use the third-party ``frozendict`` package if installed; otherwise fall +# back to a small pure-Python equivalent (PEP 814). +try: + from frozendict import frozendict # type: ignore[import-not-found] +except ImportError: + class frozendict(dict): # type: ignore[no-redef] + """Hashable, read-only ``dict`` used as an ``lru_cache`` key.""" + __slots__ = () + + def __hash__(self): # type: ignore[override] + return hash(frozenset(self.items())) + + def __setitem__(self, *args, **kwargs): + raise TypeError(f"'{type(self).__name__}' object doesn't support item assignment") + + def __delitem__(self, *args, **kwargs): + raise TypeError(f"'{type(self).__name__}' object doesn't support item deletion") + + def _readonly(self, *args, **kwargs): + raise AttributeError(f"'{type(self).__name__}' object is read-only") + + pop = _readonly # type: ignore[assignment] + popitem = _readonly # type: ignore[assignment] + clear = _readonly # type: ignore[assignment] + update = _readonly # type: ignore[assignment] + setdefault = _readonly # type: ignore[assignment] + + # From https://stackoverflow.com/a/59128615 def attributes(obj): disallowed_names = {