Repository: dr-prodigy/python-holidays

Issue:
Maximize load performance 
We advertise this library as "fast, efficient", and is performance or memory footprint affected by loading every single class for every single country (that's 360+ classes) even when the user needs holidays only for a single country, which I assume this to be 90%+ of use cases, or just a few countries? What about on small footprints (e.g. Raspberry Pi)? I would assume so, but am not very familiar with Python innards.

If so, are there architectures that we can implement to maximize load performance especially when only one (or a few) countries are needed?

_Originally posted by @mborsetti in https://github.com/dr-prodigy/python-holidays/issues/948#issuecomment-1432382359_
            

Comments:

Patch:
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index 5d982e6a..b1b98965 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -100,7 +100,7 @@ jobs:
           name: dist
           path: dist
       - name: Publish Package
-        uses: pypa/gh-action-pypi-publish@v1.8.5
+        uses: pypa/gh-action-pypi-publish@v1.8.6
         with:
           user: __token__
           password: ${{ secrets.pypi_password }}
diff --git a/holidays/__init__.py b/holidays/__init__.py
index bc268a05..294c676e 100644
--- a/holidays/__init__.py
+++ b/holidays/__init__.py
@@ -10,9 +10,12 @@
 #  License: MIT (see LICENSE file)
 
 from holidays.constants import *
-from holidays.countries import *
-from holidays.financial import *
 from holidays.holiday_base import *
+from holidays.registry import EntityLoader
 from holidays.utils import *
 
 __version__ = "0.25"
+
+
+EntityLoader.load("countries", globals())
+EntityLoader.load("financial", globals())
diff --git a/holidays/registry.py b/holidays/registry.py
new file mode 100644
index 00000000..ca8ee397
--- /dev/null
+++ b/holidays/registry.py
@@ -0,0 +1,257 @@
+#  python-holidays
+#  ---------------
+#  A fast, efficient Python library for generating country, province and state
+#  specific sets of holidays on the fly. It aims to make determining whether a
+#  specific date is a holiday as fast and flexible as possible.
+#
+#  Authors: dr-prodigy <dr.prodigy.github@gmail.com> (c) 2017-2023
+#           ryanss <ryanssdev@icloud.com> (c) 2014-2017
+#  Website: https://github.com/dr-prodigy/python-holidays
+#  License: MIT (see LICENSE file)
+
+import importlib
+from typing import Any, Dict, Iterable, Optional, Tuple, Union
+
+from holidays.holiday_base import HolidayBase
+
+RegistryDict = Dict[str, Tuple[str, ...]]
+
+COUNTRIES: RegistryDict = {
+    "albania": ("Albania", "AL", "ALB"),
+    "american_samoa": ("AmericanSamoa", "AS", "ASM", "HolidaysAS"),
+    "andorra": ("Andorra", "AD", "AND"),
+    "angola": ("Angola", "AO", "AGO"),
+    "argentina": ("Argentina", "AR", "ARG"),
+    "armenia": ("Armenia", "AM", "ARM"),
+    "aruba": ("Aruba", "AW", "ABW"),
+    "australia": ("Australia", "AU", "AUS"),
+    "austria": ("Austria", "AT", "AUT"),
+    "azerbaijan": ("Azerbaijan", "AZ", "AZE"),
+    "bahrain": ("Bahrain", "BH", "BAH"),
+    "bangladesh": ("Bangladesh", "BD", "BGD"),
+    "belarus": ("Belarus", "BY", "BLR"),
+    "belgium": ("Belgium", "BE", "BEL"),
+    "bolivia": ("Bolivia", "BO", "BOL"),
+    "bosnia_and_herzegovina": ("BosniaAndHerzegovina", "BA", "BIH"),
+    "botswana": ("Botswana", "BW", "BWA"),
+    "brazil": ("Brazil", "BR", "BRA"),
+    "bulgaria": ("Bulgaria", "BG", "BLG"),
+    "burundi": ("Burundi", "BI", "BDI"),
+    "canada": ("Canada", "CA", "CAN"),
+    "chile": ("Chile", "CL", "CHL"),
+    "china": ("China", "CN", "CHN"),
+    "colombia": ("Colombia", "CO", "COL"),
+    "costa_rica": ("CostaRica", "CR", "CRI"),
+    "croatia": ("Croatia", "HR", "HRV"),
+    "cuba": ("Cuba", "CU", "CUB"),
+    "curacao": ("Curacao", "CW", "CUW"),
+    "cyprus": ("Cyprus", "CY", "CYP"),
+    "czechia": ("Czechia", "CZ", "CZE"),
+    "denmark": ("Denmark", "DK", "DNK"),
+    "djibouti": ("Djibouti", "DJ", "DJI"),
+    "dominican_republic": ("DominicanRepublic", "DO", "DOM"),
+    "ecuador": ("Ecuador", "EC", "ECU"),
+    "egypt": ("Egypt", "EG", "EGY"),
+    "estonia": ("Estonia", "EE", "EST"),
+    "eswatini": ("Eswatini", "SZ", "SZW", "Swaziland"),
+    "ethiopia": ("Ethiopia", "ET", "ETH"),
+    "finland": ("Finland", "FI", "FIN"),
+    "france": ("France", "FR", "FRA"),
+    "georgia": ("Georgia", "GE", "GEO"),
+    "germany": ("Germany", "DE", "DEU"),
+    "greece": ("Greece", "GR", "GRC"),
+    "guam": ("Guam", "GU", "GUM", "HolidaysGU"),
+    "honduras": ("Honduras", "HN", "HND"),
+    "hongkong": ("HongKong", "HK", "HKG"),
+    "hungary": ("Hungary", "HU", "HUN"),
+    "iceland": ("Iceland", "IS", "ISL"),
+    "india": ("India", "IN", "IND"),
+    "indonesia": ("Indonesia", "ID", "IDN"),
+    "ireland": ("Ireland", "IE", "IRL"),
+    "isle_of_man": ("IsleOfMan", "IM", "IMN"),
+    "israel": ("Israel", "IL", "ISR"),
+    "italy": ("Italy", "IT", "ITA"),
+    "jamaica": ("Jamaica", "JM", "JAM"),
+    "japan": ("Japan", "JP", "JPN"),
+    "kazakhstan": ("Kazakhstan", "KZ", "KAZ"),
+    "kenya": ("Kenya", "KE", "KEN"),
+    "kyrgyzstan": ("Kyrgyzstan", "KG", "KGZ"),
+    "latvia": ("Latvia", "LV", "LVA"),
+    "lesotho": ("Lesotho", "LS", "LSO"),
+    "liechtenstein": ("Liechtenstein", "LI", "LIE"),
+    "lithuania": ("Lithuania", "LT", "LTU"),
+    "luxembourg": ("Luxembourg", "LU", "LUX"),
+    "madagascar": ("Madagascar", "MG", "MDG"),
+    "malawi": ("Malawi", "MW", "MWI"),
+    "malaysia": ("Malaysia", "MY", "MYS"),
+    "malta": ("Malta", "MT", "MLT"),
+    "marshall_islands": ("MarshallIslands", "MH", "MHL", "HolidaysMH"),
+    "mexico": ("Mexico", "MX", "MEX"),
+    "moldova": ("Moldova", "MD", "MDA"),
+    "monaco": ("Monaco", "MC", "MCO"),
+    "montenegro": ("Montenegro", "ME", "MNE"),
+    "morocco": ("Morocco", "MA", "MOR"),
+    "mozambique": ("Mozambique", "MZ", "MOZ"),
+    "namibia": ("Namibia", "NA", "NAM"),
+    "netherlands": ("Netherlands", "NL", "NLD"),
+    "new_zealand": ("NewZealand", "NZ", "NZL"),
+    "nicaragua": ("Nicaragua", "NI", "NIC"),
+    "nigeria": ("Nigeria", "NG", "NGA"),
+    "north_macedonia": ("NorthMacedonia", "MK", "MKD"),
+    "northern_mariana_islands": (
+        "NorthernMarianaIslands",
+        "MP",
+        "MNP",
+        "HolidaysMP",
+    ),
+    "norway": ("Norway", "NO", "NOR"),
+    "pakistan": ("Pakistan", "PK", "PAK"),
+    "panama": ("Panama", "PA", "PAN"),
+    "paraguay": ("Paraguay", "PY", "PRY"),
+    "peru": ("Peru", "PE", "PER"),
+    "philippines": ("Philippines", "PH", "PHL"),
+    "poland": ("Poland", "PL", "POL"),
+    "portugal": ("Portugal", "PT", "PRT"),
+    "puerto_rico": ("PuertoRico", "PR", "PRI", "HolidaysPR"),
+    "romania": ("Romania", "RO", "ROU"),
+    "russia": ("Russia", "RU", "RUS"),
+    "san_marino": ("SanMarino", "SM", "SMR"),
+    "saudi_arabia": ("SaudiArabia", "SA", "SAU"),
+    "serbia": ("Serbia", "RS", "SRB"),
+    "singapore": ("Singapore", "SG", "SGP"),
+    "slovakia": ("Slovakia", "SK", "SVK"),
+    "slovenia": ("Slovenia", "SI", "SVN"),
+    "south_africa": ("SouthAfrica", "ZA", "ZAF"),
+    "south_korea": ("SouthKorea", "KR", "KOR", "Korea"),
+    "spain": ("Spain", "ES", "ESP"),
+    "sweden": ("Sweden", "SE", "SWE"),
+    "switzerland": ("Switzerland", "CH", "CHE"),
+    "taiwan": ("Taiwan", "TW", "TWN"),
+    "thailand": ("Thailand", "TH", "THA"),
+    "tunisia": ("Tunisia", "TN", "TUN"),
+    "turkey": ("Turkey", "TR", "TUR"),
+    "ukraine": ("Ukraine", "UA", "UKR"),
+    "united_arab_emirates": ("UnitedArabEmirates", "AE", "ARE"),
+    "united_kingdom": ("UnitedKingdom", "GB", "GBR", "UK"),
+    "united_states_minor_outlying_islands": (
+        "UnitedStatesMinorOutlyingIslands",
+        "UM",
+        "UMI",
+        "HolidaysUM",
+    ),
+    "united_states_virgin_islands": (
+        "UnitedStatesVirginIslands",
+        "VI",
+        "VIR",
+        "HolidaysVI",
+    ),
+    "united_states": ("UnitedStates", "US", "USA"),
+    "uruguay": ("Uruguay", "UY", "URY"),
+    "uzbekistan": ("Uzbekistan", "UZ", "UZB"),
+    "vatican_city": ("VaticanCity", "VA", "VAT"),
+    "venezuela": ("Venezuela", "VE", "VEN"),
+    "vietnam": ("Vietnam", "VN", "VNM"),
+    "zambia": ("Zambia", "ZM", "ZMB"),
+    "zimbabwe": ("Zimbabwe", "ZW", "ZWE"),
+}
+
+FINANCIAL: RegistryDict = {
+    "european_central_bank": ("EuropeanCentralBank", "ECB", "TAR"),
+    "ny_stock_exchange": ("NewYorkStockExchange", "NYSE", "XNYS"),
+}
+
+
+class EntityLoader:
+    """Country and financial holidays entities lazy loader."""
+
+    __slots__ = ("entity", "entity_name", "module_name")
+
+    def __init__(self, path: str, *args, **kwargs) -> None:
+        """Set up a lazy loader."""
+        self.entity = None
+
+        entity_path = path.split(".")
+        self.entity_name = entity_path[-1]
+        self.module_name = ".".join(entity_path[0:-1])
+
+        super().__init__(*args, **kwargs)
+
+    def __call__(self, *args, **kwargs) -> HolidayBase:
+        """Create a new instance of a lazy-loaded entity."""
+        cls = self.get_entity()
+        return cls(*args, **kwargs)  # type: ignore[misc, operator]
+
+    def __getattr__(self, name: str) -> Optional[Any]:
+        """Return attribute of a lazy-loaded entity."""
+        cls = self.get_entity()
+        return getattr(cls, name)
+
+    def __str__(self) -> str:
+        """Return lazy loader object string representation."""
+        return (
+            f"A lazy loader for {self.get_entity()}. For inheritance please "
+            f"use the '{self.module_name}.{self.entity_name}' class directly."
+        )
+
+    def get_entity(self) -> Optional[HolidayBase]:
+        """Return lazy-loaded entity."""
+        if self.entity is None:
+            self.entity = getattr(
+                importlib.import_module(self.module_name),
+                self.entity_name,
+            )
+
+        return self.entity
+
+    @staticmethod
+    def _get_entity_codes(
+        container: RegistryDict,
+        entity_length: Union[int, Iterable[int]],
+        include_aliases: bool = True,
+    ) -> Iterable[str]:
+        entity_length = (
+            {entity_length}
+            if isinstance(entity_length, int)
+            else set(entity_length)
+        )
+        for entities in container.values():
+            for entity in entities:
+                if len(entity) in entity_length:
+                    yield entity
+                    # Assuming that the alpha-2 code goes first.
+                    if not include_aliases:
+                        break
+
+    @staticmethod
+    def get_country_codes(include_aliases: bool = True) -> Iterable[str]:
+        """Get supported country codes.
+
+        :param include_aliases:
+            Whether to include entity aliases (e.g. UK for GB).
+        """
+        return EntityLoader._get_entity_codes(COUNTRIES, 2, include_aliases)
+
+    @staticmethod
+    def get_financial_codes(include_aliases: bool = True) -> Iterable[str]:
+        """Get supported financial codes.
+
+        :param include_aliases:
+            Whether to include entity aliases(e.g. TAR for ECB, XNYS for NYSE).
+        """
+        return EntityLoader._get_entity_codes(
+            FINANCIAL, (3, 4), include_aliases
+        )
+
+    @staticmethod
+    def load(prefix: str, scope: Dict) -> None:
+        """Load country or financial entities."""
+        entity_mapping = COUNTRIES if prefix == "countries" else FINANCIAL
+        for module, entities in entity_mapping.items():
+            scope.update(
+                {
+                    entity: EntityLoader(
+                        f"holidays.{prefix}.{module}.{entity}"
+                    )
+                    for entity in entities
+                }
+            )
diff --git a/holidays/utils.py b/holidays/utils.py
index 0c527f8e..8df9d96a 100755
--- a/holidays/utils.py
+++ b/holidays/utils.py
@@ -17,13 +17,12 @@ __all__ = (
     "list_supported_financial",
 )
 
-import inspect
 import warnings
 from functools import lru_cache
 from typing import Dict, Iterable, List, Optional, Union
 
-from holidays import countries, financial
 from holidays.holiday_base import HolidayBase
+from holidays.registry import EntityLoader
 
 
 def country_holidays(
@@ -176,8 +175,10 @@ def country_holidays(
     :class:`HolidayBase` class and define your own :meth:`_populate` method.
     See documentation for examples.
     """
+    import holidays
+
     try:
-        return getattr(countries, country)(
+        return getattr(holidays, country)(
             years=years,
             subdiv=subdiv,
             expand=expand,
@@ -238,8 +239,10 @@ def financial_holidays(
     See :py:func:`country_holidays` documentation for further details and
     examples.
     """
+    import holidays
+
     try:
-        return getattr(financial, market)(
+        return getattr(holidays, market)(
             years=years,
             subdiv=subdiv,
             expand=expand,
@@ -286,10 +289,11 @@ def list_supported_countries(include_aliases=True) -> Dict[str, List[str]]:
         A dictionary where the key is the ISO 3166-1 Alpha-2 country codes and
         the value is a list of supported subdivision codes.
     """
+    import holidays
+
     return {
-        name if include_aliases else cls.country: list(cls.subdivisions)
-        for name, cls in inspect.getmembers(countries, inspect.isclass)
-        if len(name) == 2 and issubclass(cls, HolidayBase)
+        country_code: list(getattr(holidays, country_code).subdivisions)
+        for country_code in EntityLoader.get_country_codes(include_aliases)
     }
 
 
@@ -305,8 +309,9 @@ def list_supported_financial(include_aliases=True) -> Dict[str, List[str]]:
         A dictionary where the key is the market codes and
         the value is a list of supported subdivision codes.
     """
+    import holidays
+
     return {
-        name if include_aliases else cls.market: list(cls.subdivisions)
-        for name, cls in inspect.getmembers(financial, inspect.isclass)
-        if len(name) in {3, 4} and issubclass(cls, HolidayBase)
+        financial_code: list(getattr(holidays, financial_code).subdivisions)
+        for financial_code in EntityLoader.get_financial_codes(include_aliases)
     }


Test patch:
diff --git a/tests/test_holiday_base.py b/tests/test_holiday_base.py
index 5d4af3e9..7bfa8ae4 100644
--- a/tests/test_holiday_base.py
+++ b/tests/test_holiday_base.py
@@ -20,7 +20,7 @@ from dateutil.relativedelta import MO
 from dateutil.relativedelta import relativedelta as rd
 
 import holidays
-from holidays.constants import JAN, FEB, MON, TUE, SAT, SUN
+from holidays.constants import JAN, FEB, OCT, MON, TUE, SAT, SUN
 
 
 class TestBasics(unittest.TestCase):
@@ -449,10 +449,10 @@ class TestBasics(unittest.TestCase):
         self.assertRaises(TypeError, lambda: 1 + holidays.US())
 
     def test_inheritance(self):
-        class NoColumbusHolidays(holidays.US):
+        class NoColumbusHolidays(holidays.countries.US):
             def _populate(self, year):
-                holidays.US._populate(self, year)
-                self.pop(date(year, 10, 1) + rd(weekday=MO(+2)))
+                super()._populate(year)
+                self.pop(date(year, OCT, 1) + rd(weekday=MO(+2)))
 
         hdays = NoColumbusHolidays()
         self.assertIn(date(2014, 10, 13), self.holidays)
@@ -462,9 +462,9 @@ class TestBasics(unittest.TestCase):
         self.assertNotIn(date(2020, 10, 12), hdays)
         self.assertIn(date(2020, 1, 1), hdays)
 
-        class NinjaTurtlesHolidays(holidays.US):
+        class NinjaTurtlesHolidays(holidays.countries.US):
             def _populate(self, year):
-                holidays.US._populate(self, year)
+                super()._populate(year)
                 self[date(year, 7, 13)] = "Ninja Turtle's Day"
 
         hdays = NinjaTurtlesHolidays()
diff --git a/tests/test_registry.py b/tests/test_registry.py
new file mode 100644
index 00000000..66b93f6f
--- /dev/null
+++ b/tests/test_registry.py
@@ -0,0 +1,112 @@
+#  python-holidays
+#  ---------------
+#  A fast, efficient Python library for generating country, province and state
+#  specific sets of holidays on the fly. It aims to make determining whether a
+#  specific date is a holiday as fast and flexible as possible.
+#
+#  Authors: dr-prodigy <dr.prodigy.github@gmail.com> (c) 2017-2023
+#           ryanss <ryanssdev@icloud.com> (c) 2014-2017
+#  Website: https://github.com/dr-prodigy/python-holidays
+#  License: MIT (see LICENSE file)
+
+import importlib
+import inspect
+import warnings
+from unittest import TestCase
+
+from holidays import countries, financial, registry
+
+
+class TestEntityLoader(TestCase):
+    # TODO(ark): consider running this just once for the latest Python version.
+    def test_countries_imports(self):
+        warnings.simplefilter("ignore")
+
+        import holidays
+
+        loader_entities = set()
+        for module, entities in registry.COUNTRIES.items():
+            module = importlib.import_module(f"holidays.countries.{module}")
+            for entity in entities:
+                countries_cls = getattr(countries, entity)
+                loader_cls = getattr(holidays, entity)
+                module_cls = getattr(module, entity)
+
+                self.assertIsNotNone(countries_cls, entity)
+                self.assertIsNotNone(loader_cls, entity)
+                self.assertIsNotNone(module_cls, entity)
+                self.assertEqual(countries_cls, module_cls)
+                self.assertTrue(isinstance(loader_cls, registry.EntityLoader))
+                self.assertTrue(isinstance(loader_cls(), countries_cls))
+                self.assertTrue(isinstance(loader_cls(), module_cls))
+
+                loader_entities.add(loader_cls.__name__)
+
+        countries_entities = set(
+            entity[0]
+            for entity in inspect.getmembers(countries, inspect.isclass)
+        )
+        self.assertEqual(
+            countries_entities,
+            loader_entities,
+            "Registry entities and countries entities don't match: %s"
+            % countries_entities.difference(loader_entities),
+        )
+
+    def test_country_str(self):
+        self.assertEqual(
+            str(
+                registry.EntityLoader(
+                    "holidays.countries.united_states.US",
+                )
+            ),
+            "A lazy loader for <class 'holidays.countries.united_states.US'>. "
+            "For inheritance please use the "
+            "'holidays.countries.united_states.US' class directly.",
+        )
+
+    # TODO(ark): consider running this just once for the latest Python version.
+    def test_financial_imports(self):
+        import holidays
+
+        loader_entities = set()
+        for module, entities in registry.FINANCIAL.items():
+            module = importlib.import_module(f"holidays.financial.{module}")
+            for entity in entities:
+                financial_cls = getattr(financial, entity)
+                loader_cls = getattr(holidays, entity)
+                module_cls = getattr(module, entity)
+
+                self.assertIsNotNone(financial_cls, entity)
+                self.assertIsNotNone(loader_cls, entity)
+                self.assertIsNotNone(module_cls, entity)
+                self.assertEqual(financial_cls, module_cls)
+                self.assertTrue(isinstance(loader_cls, registry.EntityLoader))
+                self.assertTrue(isinstance(loader_cls(), financial_cls))
+                self.assertTrue(isinstance(loader_cls(), module_cls))
+
+                loader_entities.add(loader_cls.__name__)
+
+        financial_entities = set(
+            entity[0]
+            for entity in inspect.getmembers(financial, inspect.isclass)
+        )
+        self.assertEqual(
+            financial_entities,
+            loader_entities,
+            "Registry entities and financial entities don't match: %s"
+            % financial_entities.difference(loader_entities),
+        )
+
+    def test_financial_str(self):
+        self.assertEqual(
+            str(
+                registry.EntityLoader(
+                    "holidays.financial.ny_stock_exchange.NYSE"
+                )
+            ),
+            "A lazy loader for "
+            "<class 'holidays.financial.ny_stock_exchange.NYSE'>. "
+            "For inheritance please use the "
+            "'holidays.financial.ny_stock_exchange.NYSE' class directly.",
+        )