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",
[