This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 42164201d42 Make Google deprecation date parsing locale independent 
(#69405)
42164201d42 is described below

commit 42164201d42309f4537a17b4cac1fb37f58c556e
Author: Anas Khan <[email protected]>
AuthorDate: Sat Sep 19 12:17:45 2026 +0530

    Make Google deprecation date parsing locale independent (#69405)
    
    The @deprecated decorator parsed and formatted the planned_removal_date
    with strptime/strftime using the %B directive, which resolves month names
    against the process LC_TIME locale. Under a non-English locale (for example
    de_DE or fr_FR) strptime cannot match the English month name and raises,
    so importing any module that decorates a member with planned_removal_date
    at class-body level (such as the BigQuery operators) fails at import time.
    The same directive also emitted a localized month name in the deprecation
    message.
    
    Translate the English month name to its number through a fixed mapping and
    parse the numeric parts with locale-independent directives, and format the
    sunset message with the same fixed English month names. The accepted input
    format, the error message, and the rendered output are unchanged.
---
 .../airflow/providers/google/common/deprecated.py  | 29 ++++++++++-
 .../tests/unit/google/common/test_deprecated.py    | 60 +++++++++++++++-------
 2 files changed, 69 insertions(+), 20 deletions(-)

diff --git a/providers/google/src/airflow/providers/google/common/deprecated.py 
b/providers/google/src/airflow/providers/google/common/deprecated.py
index 00ea534a9a0..e7768ca8d7e 100644
--- a/providers/google/src/airflow/providers/google/common/deprecated.py
+++ b/providers/google/src/airflow/providers/google/common/deprecated.py
@@ -25,6 +25,26 @@ from typing import Any
 from deprecated import deprecated as standard_deprecated
 from deprecated.classic import ClassicAdapter
 
+# English month names used by the `Month DD, YYYY` date format. Hardcoded 
rather than
+# derived from `calendar`/`strftime` because the `%B` directive is 
locale-dependent:
+# under a non-English LC_TIME it fails to parse the English month at 
decoration time and
+# emits a localized month name in the deprecation message.
+_MONTH_NAMES = (
+    "January",
+    "February",
+    "March",
+    "April",
+    "May",
+    "June",
+    "July",
+    "August",
+    "September",
+    "October",
+    "November",
+    "December",
+)
+_MONTH_NUMBERS = {name.lower(): number for number, name in 
enumerate(_MONTH_NAMES, start=1)}
+
 
 class AirflowDeprecationAdapter(ClassicAdapter):
     """
@@ -86,7 +106,11 @@ class AirflowDeprecationAdapter(ClassicAdapter):
     def _validate_date(value: str | None) -> date | None:
         if value:
             try:
-                return datetime.strptime(value, "%B %d, %Y").date()
+                month_name, separator, day_and_year = value.partition(" ")
+                month_number = _MONTH_NUMBERS.get(month_name.lower())
+                if not separator or month_number is None:
+                    raise ValueError(f"Unknown month name in {value!r}.")
+                return datetime.strptime(f"{month_number} {day_and_year}", "%m 
%d, %Y").date()
             except ValueError as ex:
                 error_message = (
                     f"Invalid date '{value}'. "
@@ -126,7 +150,8 @@ class AirflowDeprecationAdapter(ClassicAdapter):
 
     def sunset_message(self) -> str:
         if self.planned_removal_date:
-            return f"after {self.planned_removal_date.strftime('%B %d, %Y')}"
+            month_name = _MONTH_NAMES[self.planned_removal_date.month - 1]
+            return f"after {month_name} 
{self.planned_removal_date.strftime('%d, %Y')}"
         if self.planned_removal_release:
             return f"since version {self.planned_removal_release}"
         return "in the future"
diff --git a/providers/google/tests/unit/google/common/test_deprecated.py 
b/providers/google/tests/unit/google/common/test_deprecated.py
index 6b25ad9c162..d1855d6f7f1 100644
--- a/providers/google/tests/unit/google/common/test_deprecated.py
+++ b/providers/google/tests/unit/google/common/test_deprecated.py
@@ -16,6 +16,8 @@
 # under the License.
 from __future__ import annotations
 
+import contextlib
+import locale
 from datetime import date
 from unittest import mock
 
@@ -27,6 +29,20 @@ ADAPTER_PATH = "airflow.providers.google.common.deprecated"
 ADAPTER_CLASS_PATH = f"{ADAPTER_PATH}.AirflowDeprecationAdapter"
 
 
[email protected]
+def _time_locale(name: str):
+    """Temporarily set LC_TIME, skipping the test if the locale is 
unavailable."""
+    saved = locale.setlocale(locale.LC_TIME)
+    try:
+        locale.setlocale(locale.LC_TIME, name)
+    except locale.Error:
+        pytest.skip(f"locale {name!r} is not available on this system")
+    try:
+        yield
+    finally:
+        locale.setlocale(locale.LC_TIME, saved)
+
+
 class TestAirflowDeprecationAdapter:
     @mock.patch(f"{ADAPTER_CLASS_PATH}._validate_fields")
     @mock.patch(f"{ADAPTER_CLASS_PATH}._validate_removal_release")
@@ -45,25 +61,22 @@ class TestAirflowDeprecationAdapter:
         assert adapter.planned_removal_date == mock_date
         assert adapter.planned_removal_release == mock_release
 
-    @mock.patch(f"{ADAPTER_PATH}.datetime")
-    def test_validate_date(self, mock_datetime):
-        value = "August 22, 2024"
-        expected_date = date(2024, 8, 22)
-        mock_datetime.strptime.return_value.date.return_value = expected_date
-
-        actual_date = AirflowDeprecationAdapter._validate_date(value)
-
-        assert actual_date == expected_date
-        mock_datetime.strptime.assert_called_once_with(value, "%B %d, %Y")
-
-    @mock.patch(f"{ADAPTER_PATH}.datetime")
-    def test_validate_date_none(self, mock_datetime):
-        value = None
-
-        actual_date = AirflowDeprecationAdapter._validate_date(value)
+    @pytest.mark.parametrize(
+        ("value", "expected_date"),
+        [
+            ("August 22, 2024", date(2024, 8, 22)),
+            ("June 30, 2026", date(2026, 6, 30)),
+            ("January 1, 2020", date(2020, 1, 1)),
+            # Month names are matched case-insensitively, as the previous `%B` 
parsing did.
+            ("august 22, 2024", date(2024, 8, 22)),
+            ("AUGUST 22, 2024", date(2024, 8, 22)),
+        ],
+    )
+    def test_validate_date(self, value, expected_date):
+        assert AirflowDeprecationAdapter._validate_date(value) == expected_date
 
-        assert actual_date is None
-        assert not mock_datetime.strptime.called
+    def test_validate_date_none(self):
+        assert AirflowDeprecationAdapter._validate_date(None) is None
 
     @pytest.mark.parametrize(
         "invalid_date",
@@ -82,6 +95,17 @@ class TestAirflowDeprecationAdapter:
         with pytest.raises(ValueError, match=expected_error_message):
             AirflowDeprecationAdapter(planned_removal_date=invalid_date)
 
+    @pytest.mark.parametrize("locale_name", ["de_DE.UTF-8", "fr_FR.UTF-8"])
+    def test_validate_date_is_locale_independent(self, locale_name):
+        with _time_locale(locale_name):
+            assert AirflowDeprecationAdapter._validate_date("June 30, 2026") 
== date(2026, 6, 30)
+
+    @pytest.mark.parametrize("locale_name", ["de_DE.UTF-8", "fr_FR.UTF-8"])
+    def test_sunset_message_uses_english_month_regardless_of_locale(self, 
locale_name):
+        with _time_locale(locale_name):
+            adapter = AirflowDeprecationAdapter(planned_removal_date="June 30, 
2026")
+            assert adapter.sunset_message() == "after June 30, 2026"
+
     @pytest.mark.parametrize(
         "release_string",
         [

Reply via email to