Repository: vacanza/python-holidays

Issue:
Implement subdivision aliases support
Per https://github.com/vacanza/python-holidays/pull/1656 discussion it'd be great to have an ability to assign aliases to ISO3166-2 subdivision codes.

Let's figure out complexity and priority of the task (v0 vs v1) and implement this feature. It would provide better user experience.


Discussion:
BTW I could help implementing this, but I noticed that you seem to have very strict ideas, how the API should work and I guess also how it should be implemented. If you don't have the time to do it yourself and you give me some valuable hints, I could help out …
Yeah, that'd be much appreciated. 

The main priority for the current PH version is compatibility. This may be the reason for the seemed strictness you've mentioned. Then goes performance and code readability. Those are sort of my promises to previous code author/maintainer.

We've started working on v1 of the library. If you have improvement ideas or with to participate in some other way -- check out https://github.com/vacanza/python-holidays/issues/1649
In other words the only strict requirement for any contribution is compatible and well tested code made right from performance perspective (we have ~5M monthly downloads relying on that).
I can have a go, if you like. Three different approaches come do mind (all without in-depth investigation): Using a dictionary, where the key is the official ISO code and the value a tuple of all aliases. Secondly, use a tuple with a tuple for each subregion, where the first element is the official ISO code and all other elements aliases. And finally keep the subregion tuple as it is and have a separate dictionary with the aliases. Any preferences?
The idea with mapping is right -- use alias subdiv as a key and iso3166 code as a value:
```python
subdivisions_aliases = {
    "B": "1",
    "K": "2",
    ...
}
```

This way you'd have average O(1) lookup. Then just swap alias to an official subdiv code at right place of `HolidayBase::init`. I guess that would be it (no in-depth investigation as well). You'll need to update `TestArgs::test_subiv` of `tests/test_holiday_base.py` to make sure it works as expected.

An apparent drawback of this simple approach is that the holidays object would have the subdiv code implicitly changed from the user perspective.

Just create a PR or use this thread for further questions if any.
I also thought about this approach, because of the same reason: the quick lookup. The reason why I did not propose it, was the other major drawback: You are limited to just one alias. In the Austrian case: You can only use 'N', but not 'NÖ' or even 'Niederösterreich', all valid and widely used aliases for the subregion 3!
Thinking further about it, we could use:
```python3
subdivisions_aliases = {
    "B": "1",
    "K": "2",
    "N": "3",
    "NÖ": "3",
    "Niederösterreich": "3",
    ...
}
```
Quick lookup vs. readability …
I believe that wouldn't be the case:
```python
subdivisions_aliases = {
    "N": "3",
    "NÖ": "3",
    "Niederösterreich": "3"
}
```
All mapped to "3". Am I missing something?

Same idea at the same time! :sweat_smile:

Resolving patch:
diff --git a/holidays/countries/austria.py b/holidays/countries/austria.py
--- a/holidays/countries/austria.py
+++ b/holidays/countries/austria.py
@@ -21,16 +21,48 @@ class Austria(HolidayBase, ChristianHolidays, InternationalHolidays):
     default_language = "de"
     supported_categories = (BANK, PUBLIC)
     supported_languages = ("de", "en_US", "uk")
-    subdivisions = ("1", "2", "3", "4", "5", "6", "7", "8", "9")
+    subdivisions = (
+        "1",  # Burgenland.
+        "2",  # Kärnten.
+        "3",  # Niederösterreich.
+        "4",  # Oberösterreich.
+        "5",  # Salzburg.
+        "6",  # Steiermark.
+        "7",  # Tirol.
+        "8",  # Vorarlberg.
+        "9",  # Wien.
+    )
+    subdivisions_aliases = {
+        "Burgenland": "1",
+        "Bgld": "1",
+        "B": "1",
+        "Kärnten": "2",
+        "Ktn": "2",
+        "K": "2",
+        "Niederösterreich": "3",
+        "NÖ": "3",
+        "N": "3",
+        "Oberösterreich": "4",
+        "OÖ": "4",
+        "O": "4",
+        "Salzburg": "5",
+        "Sbg": "5",
+        "S": "5",
+        "Steiermark": "6",
+        "Stmk": "6",
+        "St": "6",
+        "Tirol": "7",
+        "T": "7",
+        "Vorarlberg": "8",
+        "Vbg": "8",
+        "V": "8",
+        "Wien": "9",
+        "W": "9",
+    }
 
     def __init__(self, *args, **kwargs) -> None:
         ChristianHolidays.__init__(self)
         InternationalHolidays.__init__(self)
-
-        # Set the default subdivision.
-        if not kwargs.get("subdiv", kwargs.get("state")):
-            kwargs["subdiv"] = "9"
-
         super().__init__(*args, **kwargs)
 
     def _populate_public_holidays(self):
@@ -58,11 +90,12 @@ def _populate_public_holidays(self):
         # Assumption Day.
         self._add_assumption_of_mary_day(tr("Mariä Himmelfahrt"))
 
+        # National Day.
+        national_day = tr("Nationalfeiertag")
         if 1919 <= self._year <= 1934:
-            # National Day.
-            self._add_holiday_nov_12(tr("Nationalfeiertag"))
+            self._add_holiday_nov_12(national_day)
         if self._year >= 1967:
-            self._add_holiday_oct_26(tr("Nationalfeiertag"))
+            self._add_holiday_oct_26(national_day)
 
         # All Saints' Day.
         self._add_all_saints_day(tr("Allerheiligen"))
@@ -86,6 +119,46 @@ def _populate_bank_holidays(self):
         # New Year's Eve.
         self._add_new_years_eve(tr("Silvester"))
 
+    def _populate_subdiv_1_bank_holidays(self):
+        # St. Martin's Day.
+        self._add_holiday_nov_11(tr("Hl. Martin"))
+
+    def _populate_subdiv_2_bank_holidays(self):
+        # St. Joseph's Day.
+        self._add_saint_josephs_day(tr("Hl. Josef"))
+
+        # 1920 Carinthian plebiscite.
+        self._add_holiday_oct_10(tr("Tag der Volksabstimmung"))
+
+    def _populate_subdiv_3_bank_holidays(self):
+        # St. Leopold's Day.
+        self._add_holiday_nov_15(tr("Hl. Leopold"))
+
+    def _populate_subdiv_4_bank_holidays(self):
+        if self._year >= 2004:
+            # St. Florian's Day.
+            self._add_holiday_may_4(tr("Hl. Florian"))
+
+    def _populate_subdiv_5_bank_holidays(self):
+        # St. Rupert's Day.
+        self._add_holiday_sep_24(tr("Hl. Rupert"))
+
+    def _populate_subdiv_6_bank_holidays(self):
+        # St. Joseph's Day.
+        self._add_saint_josephs_day(tr("Hl. Josef"))
+
+    def _populate_subdiv_7_bank_holidays(self):
+        # St. Joseph's Day.
+        self._add_saint_josephs_day(tr("Hl. Josef"))
+
+    def _populate_subdiv_8_bank_holidays(self):
+        # St. Joseph's Day.
+        self._add_saint_josephs_day(tr("Hl. Josef"))
+
+    def _populate_subdiv_9_bank_holidays(self):
+        # St. Leopold's Day.
+        self._add_holiday_nov_15(tr("Hl. Leopold"))
+
 
 class AT(Austria):
     pass
diff --git a/holidays/holiday_base.py b/holidays/holiday_base.py
--- a/holidays/holiday_base.py
+++ b/holidays/holiday_base.py
@@ -209,6 +209,9 @@ def _populate(self, year):
     """The market's ISO 3166-1 alpha-2 code."""
     subdivisions: Tuple[str, ...] = ()
     """The subdivisions supported for this country (see documentation)."""
+    subdivisions_aliases: Dict[str, str] = {}
+    """Aliases for the ISO 3166-2 subdivision codes with the key as alias and
+    the value the ISO 3166-2 subdivision code."""
     years: Set[int]
     """The years calculated."""
     expand: bool
@@ -217,7 +220,7 @@ def _populate(self, year):
     observed: bool
     """Whether dates when public holiday are observed are included."""
     subdiv: Optional[str] = None
-    """The subdiv requested."""
+    """The subdiv requested as ISO 3166-2 code or one of the aliases."""
     special_holidays: Dict[int, Union[SpecialHoliday, SubstitutedHoliday]] = {}
     """A list of the country-wide special (as opposite to regular) holidays for
     a specific year."""
@@ -262,8 +265,8 @@ def __init__(
             following Monday). This doesn't work for all countries.
 
         :param subdiv:
-            The subdivision (e.g. state or province); not implemented for all
-            countries (see documentation).
+            The subdivision (e.g. state or province) as a ISO 3166-2 code
+            or its alias; not implemented for all countries (see documentation).
 
         :param prov:
             *deprecated* use subdiv instead.
@@ -304,10 +307,10 @@ def __init__(
             if isinstance(subdiv, int):
                 subdiv = str(subdiv)
 
+            subdivisions_aliases = tuple(sorted(self.subdivisions_aliases))
             # Unsupported subdivisions.
-            if (
-                not isinstance(self, HolidaySum)
-                and subdiv not in self.subdivisions + self._deprecated_subdivisions
+            if not isinstance(self, HolidaySum) and subdiv not in (
+                self.subdivisions + subdivisions_aliases + self._deprecated_subdivisions
             ):
                 raise NotImplementedError(
                     f"Entity `{self._entity_code}` does not have subdivision {subdiv}"
@@ -326,7 +329,9 @@ def __init__(
                 warnings.warn(
                     "This subdivision is deprecated and will be removed after "
                     "Dec, 1 2023. The list of supported subdivisions: "
-                    f"{', '.join(sorted(self.subdivisions))}.",
+                    f"{', '.join(sorted(self.subdivisions))}; "
+                    "the list of supported subdivisions aliases: "
+                    f"{', '.join(subdivisions_aliases)}.",
                     DeprecationWarning,
                 )
 
@@ -657,19 +662,32 @@ def _entity_code(self):
 
     @cached_property
     def _normalized_subdiv(self):
-        return self.subdiv.translate(
-            str.maketrans(
-                {
-                    "-": "_",
-                    " ": "_",
-                }
+        return (
+            self.subdivisions_aliases.get(self.subdiv, self.subdiv)
+            .translate(
+                str.maketrans(
+                    {
+                        "-": "_",
+                        " ": "_",
+                    }
+                )
             )
-        ).lower()
+            .lower()
+        )
 
     @cached_property
     def _sorted_categories(self):
         return sorted(self.categories)
 
+    @classmethod
+    def get_subdivision_aliases(cls) -> Dict[str, List]:
+        """Get subdivision aliases."""
+        subdivision_aliases: Dict[str, List[str]] = {s: [] for s in cls.subdivisions}
+        for alias, subdivision in cls.subdivisions_aliases.items():
+            subdivision_aliases[subdivision].append(alias)
+
+        return subdivision_aliases
+
     def _is_leap_year(self) -> bool:
         """
         Returns True if the year is leap. Returns False otherwise.
diff --git a/holidays/utils.py b/holidays/utils.py
--- a/holidays/utils.py
+++ b/holidays/utils.py
@@ -46,8 +46,8 @@ def country_holidays(
         An ISO 3166-1 Alpha-2 country code.
 
     :param subdiv:
-        The subdivision (e.g. state or province); not implemented for all
-        countries (see documentation).
+        The subdivision (e.g. state or province) as a ISO 3166-2 code
+        or its alias; not implemented for all countries (see documentation).
 
     :param years:
         The year(s) to pre-calculate public holidays for at instantiation.


Test patch:
diff --git a/tests/common.py b/tests/common.py
--- a/tests/common.py
+++ b/tests/common.py
@@ -332,6 +332,17 @@ def assertLocalizedHolidays(self, *args):
 class CommonTests(TestCase):
     """Common test cases for all entities."""
 
+    def test_subdivisions_aliases(self):
+        """Validate entity subdivisions aliases."""
+        if self.holidays.subdivisions_aliases:
+            subdivisions = set(self.holidays.subdivisions)
+            for alias, subdiv in self.holidays.subdivisions_aliases.items():
+                self.assertIn(
+                    subdiv,
+                    subdivisions,
+                    f"Invalid subdivision alias {alias}: subdivision {subdiv} does not exist.",
+                )
+
 
 class CommonCountryTests(CommonTests):
     """Common test cases for country entities."""
diff --git a/tests/countries/test_austria.py b/tests/countries/test_austria.py
--- a/tests/countries/test_austria.py
+++ b/tests/countries/test_austria.py
@@ -9,7 +9,6 @@
 #  Website: https://github.com/dr-prodigy/python-holidays
 #  License: MIT (see LICENSE file)
 
-import warnings
 from unittest import TestCase
 
 from holidays.constants import BANK
@@ -152,13 +151,6 @@ def test_bank_2022(self):
             ("2022-12-31", "Silvester"),
         )
 
-    def test_subdivisions(self):
-        warnings.simplefilter("ignore", category=DeprecationWarning)
-        for code in (9, "9", "", None):
-            self.assertEqual(AT(prov=code).subdiv, "9")
-            self.assertEqual(AT(state=code).subdiv, "9")
-            self.assertEqual(AT(subdiv=code).subdiv, "9")
-
     def test_l10n_default(self):
         self.assertLocalizedHolidays(
             ("2022-01-01", "Neujahr"),
@@ -220,3 +212,41 @@ def test_l10n_uk(self):
             ("2022-12-26", "День Святого Стефана"),
             ("2022-12-31", "Переддень Нового року"),
         )
+
+    def test_subdiv_1_bank_holidays(self):
+        subdiv_1_bank_holidays = Austria(subdiv="1", categories=BANK)
+        self.assertHolidayName("Hl. Martin", subdiv_1_bank_holidays, "2024-11-11")
+
+    def test_subdiv_2_bank_holidays(self):
+        subdiv_2_bank_holidays = Austria(subdiv="2", categories=BANK)
+        self.assertHolidayName("Hl. Josef", subdiv_2_bank_holidays, "2024-03-19")
+        self.assertHolidayName("Tag der Volksabstimmung", subdiv_2_bank_holidays, "2024-10-10")
+
+    def test_subdiv_3_bank_holidays(self):
+        subdiv_3_bank_holidays = Austria(subdiv="3", categories=BANK)
+        self.assertHolidayName("Hl. Leopold", subdiv_3_bank_holidays, "2024-11-15")
+
+    def test_subdiv_4_bank_holidays(self):
+        subdiv_4_bank_holidays = Austria(subdiv="4", categories=BANK)
+        self.assertHolidayName("Hl. Florian", subdiv_4_bank_holidays, "2024-05-04")
+        self.assertNoHoliday(subdiv_4_bank_holidays, "2003-05-04")
+
+    def test_subdiv_5_bank_holidays(self):
+        subdiv_5_bank_holidays = Austria(subdiv="5", categories=BANK)
+        self.assertHolidayName("Hl. Rupert", subdiv_5_bank_holidays, "2024-09-24")
+
+    def test_subdiv_6_bank_holidays(self):
+        subdiv_6_bank_holidays = Austria(subdiv="6", categories=BANK)
+        self.assertHolidayName("Hl. Josef", subdiv_6_bank_holidays, "2024-03-19")
+
+    def test_subdiv_7_bank_holidays(self):
+        subdiv_7_bank_holidays = Austria(subdiv="7", categories=BANK)
+        self.assertHolidayName("Hl. Josef", subdiv_7_bank_holidays, "2024-03-19")
+
+    def test_subdiv_8_bank_holidays(self):
+        subdiv_8_bank_holidays = Austria(subdiv="8", categories=BANK)
+        self.assertHolidayName("Hl. Josef", subdiv_8_bank_holidays, "2024-03-19")
+
+    def test_subdiv_9_bank_holidays(self):
+        subdiv_9_bank_holidays = Austria(subdiv="9", categories=BANK)
+        self.assertHolidayName("Hl. Leopold", subdiv_9_bank_holidays, "2024-11-15")
diff --git a/tests/test_docs.py b/tests/test_docs.py
--- a/tests/test_docs.py
+++ b/tests/test_docs.py
@@ -44,9 +44,13 @@ def test_supported_countries_table(self):
         country_default_languages = {}
         country_names = []
         country_subdivisions = {}
+        country_subdivisions_aliases = {}
         country_supported_languages = {}
         country_supported_categories = {}
-        subdivisions_re = re.compile(".*: (.*)")
+        subdivision_group_re = re.compile(".*: (.*)")
+        subdivision_and_aliases_re = re.compile(r",(?![^()]*\))")
+        subdivision_aliases_re = re.compile(r"(.*)\s\((.*?)\)")
+
         table_content = [
             line.strip()
             for line in re.findall(
@@ -67,26 +71,36 @@ def test_supported_countries_table(self):
             if country_code:
                 country_alpha_2_codes.add(country_code)
 
-            # Subdivisions: 3rd column.
+            # Subdivisions and their aliases: 3rd column.
             country_subdivisions[country_code] = []
-            subdivisions = table_content[idx + 2].strip(" -")
-            if subdivisions:
-                for subdivision in subdivisions.split("."):
-                    subdivision_group = subdivision.split(";")[0]
+            country_subdivisions_aliases[country_code] = {}
+            subdivision_str = table_content[idx + 2].strip(" -")
+            if subdivision_str:
+                for subdivision_groups in subdivision_str.split("."):
+                    subdivision_aliases_group = subdivision_groups.split(";")[0].strip()
                     # Exclude empty subdivisions.
-                    if ":" not in subdivision_group:
+                    if ":" not in subdivision_aliases_group:
                         country_subdivisions[country_code] = []
                         continue
 
-                    # Combine all subdivision codes.
-                    country_subdivisions[country_code].extend(
-                        [
-                            subdivision_code.strip("* ")
-                            for subdivision_code in subdivisions_re.findall(subdivision_group)[
-                                0
-                            ].split(",")
-                        ]
-                    )
+                    # Combine all subdivision and their aliases.
+                    subdivision_aliases_group = subdivision_group_re.findall(
+                        subdivision_aliases_group
+                    )[0]
+                    for subdivision_aliases in subdivision_and_aliases_re.split(
+                        subdivision_aliases_group
+                    ):
+                        if "," in subdivision_aliases:  # Subdivision with aliases.
+                            subdivision_aliases = subdivision_aliases_re.match(subdivision_aliases)
+                            subdivision = subdivision_aliases.group(1)
+                            aliases = subdivision_aliases.group(2).split(", ")
+                        else:
+                            aliases = []
+                            subdivision = subdivision_aliases
+                        subdivision = subdivision.strip(" *")
+
+                        country_subdivisions[country_code].append(subdivision)
+                        country_subdivisions_aliases[country_code][subdivision] = aliases
 
             # Supported Languages: 4th column.
             supported_languages = table_content[idx + 3].strip(" -")
@@ -167,6 +181,16 @@ def test_supported_countries_table(self):
                 ),
             )
 
+            # Make sure country subdivisions aliases are shown correctly.
+            subdivision_aliases = instance.get_subdivision_aliases()
+            for subdivision in instance.subdivisions:
+                self.assertEqual(
+                    subdivision_aliases.get(subdivision, []),
+                    country_subdivisions_aliases[country_code][subdivision],
+                    f"Country '{country_name}' subdivisions '{subdivision}' aliases are not shown "
+                    "correctly in the table.\n",
+                )
+
             # Make sure supported languages are shown correctly.
             if country_code in localized_countries:
                 supported_languages = localized_countries[country_code]
diff --git a/tests/test_holiday_base.py b/tests/test_holiday_base.py
--- a/tests/test_holiday_base.py
+++ b/tests/test_holiday_base.py
@@ -61,7 +61,15 @@ def _populate(self, year: int) -> None:
 
 class CountryStub1(EntityStub, StaticHolidays):
     country = "CS1"
-    subdivisions = ("Subdiv 1", "Subdiv 2")
+    subdivisions = ("Subdiv 1", "Subdiv 2", "3")
+    subdivisions_aliases = {
+        "S1": "Subdiv 1",
+        "S2": "Subdiv 2",
+        "S3": "3",
+        "S_1": "Subdiv 1",
+        "S_2": "Subdiv 2",
+        "S_3": "3",
+    }
     supported_categories = (PUBLIC, SCHOOL)
 
     def __init__(self, *args, **kwargs) -> None:
@@ -147,8 +155,28 @@ def test_observed(self):
         self.assertIn("2012-01-01", hb)
         self.assertNotIn("2012-01-02", hb)
 
-    def test_subdiv(self):
+    def test_subdivision(self):
         self.assertEqual(CountryStub1(subdiv="Subdiv 1").subdiv, "Subdiv 1")
+        self.assertEqual(CountryStub1(subdiv=3).subdiv, "3")
+
+    def test_subdivisions_aliases(self):
+        subdivisions_aliases = {
+            "subdiv_1": ("S1", "S_1"),
+            "subdiv_2": ("S2", "S_2"),
+            "3": ("S3", "S_3"),
+        }
+        for subdiv, aliases in subdivisions_aliases.items():
+            for alias in aliases:
+                self.assertEqual(subdiv, CountryStub1(subdiv=alias)._normalized_subdiv)
+
+        self.assertEqual(
+            CountryStub1.get_subdivision_aliases(),
+            {
+                "Subdiv 1": ["S1", "S_1"],
+                "Subdiv 2": ["S2", "S_2"],
+                "3": ["S3", "S_3"],
+            },
+        )
 
     def test_years(self):
         hb = HolidayBase()