From 85079d2034272bfff5baa670e589f7563f3d50e9 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 20 Oct 2026 18:09:46 +0000 Subject: [PATCH 1/3] feat(core): add runtime Python version support warning for EoL versions Add _python_version_support module (adapted from google-api-core) and invoke check_python_version(package='firebase-admin') at package import time to emit a FutureWarning on deprecated and End-of-Life Python versions (<= 3.10). --- README.md | 7 +- firebase_admin/__init__.py | 5 + firebase_admin/_python_version_support.py | 267 ++++++++++++++++++++ tests/test_python_version_support.py | 288 ++++++++++++++++++++++ 4 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 firebase_admin/_python_version_support.py create mode 100644 tests/test_python_version_support.py diff --git a/README.md b/README.md index 29303fd4..8d2ebe39 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,10 @@ requests, code review feedback, and also pull requests. ## Supported Python Versions -We currently support Python 3.9+. However, Python 3.9 support is deprecated, -and developers are strongly advised to use Python 3.10 or higher. Firebase -Admin Python SDK is also tested on PyPy and +We currently support Python 3.9+. However, Python 3.9 and 3.10 support is +deprecated and will be dropped in an upcoming minor release. Developers are +strongly advised to use Python 3.11 or higher. Firebase Admin Python SDK is +also tested on PyPy and [Google App Engine](https://cloud.google.com/appengine/) environments. diff --git a/firebase_admin/__init__.py b/firebase_admin/__init__.py index 8c9f628e..862f2246 100644 --- a/firebase_admin/__init__.py +++ b/firebase_admin/__init__.py @@ -21,9 +21,14 @@ from google.auth.credentials import Credentials as GoogleAuthCredentials from google.auth.exceptions import DefaultCredentialsError from firebase_admin import credentials +from firebase_admin import _python_version_support from firebase_admin.__about__ import __version__ +# Perform Python version check and emit a support warning if needed. +_python_version_support.check_python_version(package='firebase-admin') + + _apps = {} _apps_lock = threading.RLock() _clock = datetime.datetime.utcnow diff --git a/firebase_admin/_python_version_support.py b/firebase_admin/_python_version_support.py new file mode 100644 index 00000000..5e1b7b8c --- /dev/null +++ b/firebase_admin/_python_version_support.py @@ -0,0 +1,267 @@ +# Copyright 2026 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Code to check Python versions supported by the Firebase Admin Python SDK.""" + +import datetime +import enum +import functools +from importlib import metadata +import logging +import sys +import textwrap +from typing import Any, Dict, List, NamedTuple, Optional, Tuple +import warnings + +_LOGGER = logging.getLogger(__name__) + + +class PythonVersionStatus(enum.Enum): + """Support status of a Python version in this client library artifact release. + + "Support", in this context, means that this release of a client library + artifact is configured to run on the currently configured version of + Python. + """ + + PYTHON_VERSION_STATUS_UNSPECIFIED = 'PYTHON_VERSION_STATUS_UNSPECIFIED' + + PYTHON_VERSION_SUPPORTED = 'PYTHON_VERSION_SUPPORTED' + """This Python version is fully supported, so the artifact running on this + version will have all features and bug fixes.""" + + PYTHON_VERSION_DEPRECATED = 'PYTHON_VERSION_DEPRECATED' + """This Python version is still supported, but support will end within a + year. At that time, there will be no more releases for this artifact + running under this Python version.""" + + PYTHON_VERSION_EOL = 'PYTHON_VERSION_EOL' + """This Python version has reached its end of life in the Python community + (see https://devguide.python.org/versions/), and this artifact will cease + supporting this Python version within the next few releases.""" + + PYTHON_VERSION_UNSUPPORTED = 'PYTHON_VERSION_UNSUPPORTED' + """This release of the client library artifact may not be the latest, since + current releases no longer support this Python version.""" + + +class VersionInfo(NamedTuple): + """Hold release and support date information for a Python version.""" + + version: str + python_beta: Optional[datetime.date] + python_start: datetime.date + python_eol: datetime.date + gapic_start: Optional[datetime.date] = None # unused + gapic_deprecation: Optional[datetime.date] = None + gapic_end: Optional[datetime.date] = None + dep_unpatchable_cve: Optional[datetime.date] = None # unused + + +PYTHON_VERSIONS: List[VersionInfo] = [ + # Refer to https://devguide.python.org/versions/ and the PEPs linked therefrom. + VersionInfo( + version='3.9', + python_beta=datetime.date(2020, 5, 18), + python_start=datetime.date(2020, 10, 5), + python_eol=datetime.date(2025, 10, 5), + ), + VersionInfo( + version='3.10', + python_beta=datetime.date(2021, 5, 3), + python_start=datetime.date(2021, 10, 4), + python_eol=datetime.date(2026, 10, 4), + ), + VersionInfo( + version='3.11', + python_beta=datetime.date(2022, 5, 8), + python_start=datetime.date(2022, 10, 24), + python_eol=datetime.date(2027, 10, 24), + ), + VersionInfo( + version='3.12', + python_beta=datetime.date(2023, 5, 22), + python_start=datetime.date(2023, 10, 2), + python_eol=datetime.date(2028, 10, 2), + ), + VersionInfo( + version='3.13', + python_beta=datetime.date(2024, 5, 8), + python_start=datetime.date(2024, 10, 7), + python_eol=datetime.date(2029, 10, 7), + ), + VersionInfo( + version='3.14', + python_beta=datetime.date(2025, 5, 7), + python_start=datetime.date(2025, 10, 7), + python_eol=datetime.date(2030, 10, 7), + ), +] + +PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = { + (int(info.version.split('.')[0]), int(info.version.split('.')[1])): info + for info in PYTHON_VERSIONS +} + +LOWEST_TRACKED_VERSION = min(PYTHON_VERSION_INFO.keys()) +_FAKE_PAST_DATE = datetime.date.min + datetime.timedelta(days=900) +_FAKE_PAST_VERSION = VersionInfo( + version='0.0', + python_beta=_FAKE_PAST_DATE, + python_start=_FAKE_PAST_DATE, + python_eol=_FAKE_PAST_DATE, +) +_FAKE_FUTURE_DATE = datetime.date.max - datetime.timedelta(days=900) +_FAKE_FUTURE_VERSION = VersionInfo( + version='999.0', + python_beta=_FAKE_FUTURE_DATE, + python_start=_FAKE_FUTURE_DATE, + python_eol=_FAKE_FUTURE_DATE, +) +DEPRECATION_WARNING_PERIOD = datetime.timedelta(days=365) +EOL_GRACE_PERIOD = datetime.timedelta(weeks=1) + + +def _flatten_message(text: str) -> str: + """Dedent a multi-line string and flatten it into a single line.""" + return ' '.join(textwrap.dedent(text).strip().split()) + + +@functools.cache +def _cached_packages_distributions(): + # `importlib.metadata.packages_distributions()` is only available in Python 3.10+. + packages_distributions = getattr(metadata, 'packages_distributions', None) + if packages_distributions is None: # pragma: NO COVER + return {} + return packages_distributions() + + +def _get_pypi_package_name(module_name: str) -> Optional[str]: + """Determine the PyPI package name for a given module name.""" + try: + module_to_distributions = _cached_packages_distributions() + + if module_name in module_to_distributions: # pragma: NO COVER + return module_to_distributions[module_name][0] + except Exception as err: # pragma: NO COVER # pylint: disable=broad-except + _LOGGER.info( + 'An error occurred while determining PyPI package name for %s: %s', + module_name, + err, + ) + + return None + + +def _get_distribution_and_import_packages(import_package: str) -> Tuple[str, Any]: + """Return a pretty string with distribution & import package names.""" + distribution_package = _get_pypi_package_name(import_package) + pretty_package_name = ( + f'package {distribution_package} ({import_package})' + if distribution_package + else import_package + ) + return pretty_package_name, distribution_package + + +def check_python_version( + package: str = 'firebase-admin', + today: Optional[datetime.date] = None) -> PythonVersionStatus: + """Check the running Python version and issue a support warning if needed. + + Args: + package: The name of the package to display in the warning message. + today: The date to check against. Defaults to the current date. + + Returns: + PythonVersionStatus: The support status of the current Python version. + """ + today = today or datetime.date.today() + + python_version = sys.version_info + version_tuple = (python_version.major, python_version.minor) + py_version_str = sys.version.split()[0] + + version_info = PYTHON_VERSION_INFO.get(version_tuple) + + if not version_info: + if version_tuple < LOWEST_TRACKED_VERSION: + version_info = _FAKE_PAST_VERSION + else: + version_info = _FAKE_FUTURE_VERSION + + gapic_deprecation = version_info.gapic_deprecation or ( + version_info.python_eol - DEPRECATION_WARNING_PERIOD + ) + gapic_end = version_info.gapic_end or (version_info.python_eol + EOL_GRACE_PERIOD) + + def min_python(date: datetime.date) -> str: + """Find the minimum supported Python version for a given date.""" + for version, info in sorted(PYTHON_VERSION_INFO.items()): + if info.python_start <= date < info.python_eol: + return f'{version[0]}.{version[1]}' + return 'at a currently supported version [https://devguide.python.org/versions]' + + # Resolve the pretty package label lazily so we avoid any work on + # the happy path (supported Python version, no warning needed). + def get_package_label(): + label, _ = _get_distribution_and_import_packages(package) + return label + + if gapic_end < today: + package_label = get_package_label() + message = _flatten_message( + f""" + You are using a non-supported Python version ({py_version_str}). + Google will not post any further updates to {package_label} + supporting this Python version. Please upgrade to the latest Python + version, or at least Python {min_python(today)}, and then update + {package_label}. + """ + ) + warnings.warn(message, FutureWarning) + return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + + eol_date = version_info.python_eol + EOL_GRACE_PERIOD + if eol_date <= today <= gapic_end: + package_label = get_package_label() + message = _flatten_message( + f""" + You are using a Python version ({py_version_str}) + past its end of life. Google will update {package_label} + with critical bug fixes on a best-effort basis, but not + with any other fixes or features. Please upgrade + to the latest Python version, or at least Python + {min_python(today)}, and then update {package_label}. + """ + ) + warnings.warn(message, FutureWarning) + return PythonVersionStatus.PYTHON_VERSION_EOL + + if gapic_deprecation <= today <= gapic_end: + package_label = get_package_label() + message = _flatten_message( + f""" + You are using a Python version ({py_version_str}) which Google will + stop supporting in new releases of {package_label} once it reaches + its end of life ({version_info.python_eol}). Please upgrade to the + latest Python version, or at least Python + {min_python(version_info.python_eol)}, to continue receiving updates + for {package_label} past that date. + """ + ) + warnings.warn(message, FutureWarning) + return PythonVersionStatus.PYTHON_VERSION_DEPRECATED + + return PythonVersionStatus.PYTHON_VERSION_SUPPORTED diff --git a/tests/test_python_version_support.py b/tests/test_python_version_support.py new file mode 100644 index 00000000..b9f81116 --- /dev/null +++ b/tests/test_python_version_support.py @@ -0,0 +1,288 @@ +# Copyright 2026 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for firebase_admin._python_version_support.""" + +from collections import namedtuple +import datetime +import textwrap +from unittest.mock import patch +import warnings + +import pytest + +from firebase_admin._python_version_support import ( + PYTHON_VERSION_INFO, + PythonVersionStatus, + _cached_packages_distributions, + _flatten_message, + _get_distribution_and_import_packages, + check_python_version, +) + +# Helper object for mocking sys.version_info +VersionInfoMock = namedtuple('VersionInfoMock', ['major', 'minor']) + + +def test_flatten_message(): + """Test that _flatten_message correctly dedents and flattens a string.""" + input_text = """ + This is a multi-line + string with some + indentation. + """ + expected_output = 'This is a multi-line string with some indentation.' + assert _flatten_message(input_text) == expected_output + + +def _create_failure_message( + expected, result, py_version, date, gapic_dep, py_eol, eol_warn, gapic_end): + """Create a detailed failure message for a test.""" + return textwrap.dedent( # pragma: NO COVER + f""" + --- Test Failed --- + Expected status: {expected.name} + Received status: {result.name} + --------------------- + Context: + - Mocked Python Version: {py_version} + - Mocked Today's Date: {date} + Calculated Dates: + - gapic_deprecation: {gapic_dep} + - python_eol: {py_eol} + - eol_warning_starts: {eol_warn} + - gapic_end: {gapic_end} + """ + ) + + +def get_tracked_version_test_cases(): + """Return a list of test parameters for all tracked versions and boundary conditions.""" + results = [] + + for version_tuple, version_info in PYTHON_VERSION_INFO.items(): + py_version_str = f'{version_tuple[0]}.{version_tuple[1]}' + gapic_dep = version_info.gapic_deprecation or ( + version_info.python_eol - datetime.timedelta(days=365) + ) + gapic_end = version_info.gapic_end or ( + version_info.python_eol + datetime.timedelta(weeks=1) + ) + eol_warning_starts = version_info.python_eol + datetime.timedelta(weeks=1) + + test_cases = { + 'supported_before_deprecation_date': { + 'date': gapic_dep - datetime.timedelta(days=1), + 'expected': PythonVersionStatus.PYTHON_VERSION_SUPPORTED, + }, + 'deprecated_on_deprecation_date': { + 'date': gapic_dep, + 'expected': PythonVersionStatus.PYTHON_VERSION_DEPRECATED, + }, + 'deprecated_on_eol_date': { + 'date': version_info.python_eol, + 'expected': PythonVersionStatus.PYTHON_VERSION_DEPRECATED, + }, + 'deprecated_before_eol_warning_starts': { + 'date': eol_warning_starts - datetime.timedelta(days=1), + 'expected': PythonVersionStatus.PYTHON_VERSION_DEPRECATED, + }, + 'eol_on_eol_warning_date': { + 'date': eol_warning_starts, + 'expected': PythonVersionStatus.PYTHON_VERSION_EOL, + }, + 'eol_on_gapic_end_date': { + 'date': gapic_end, + 'expected': PythonVersionStatus.PYTHON_VERSION_EOL, + }, + 'unsupported_after_gapic_end_date': { + 'date': gapic_end + datetime.timedelta(days=1), + 'expected': PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED, + }, + } + + for name, params in test_cases.items(): + results.append( + pytest.param( + version_tuple, + params['date'], + params['expected'], + gapic_dep, + gapic_end, + eol_warning_starts, + id=f'{py_version_str}-{name}', + ) + ) + return results + + +@pytest.mark.parametrize( + 'version_tuple, mock_date, expected_status, gapic_dep, gapic_end, eol_warning_starts', + get_tracked_version_test_cases(), +) +def test_all_tracked_versions_and_date_scenarios( + version_tuple, mock_date, expected_status, gapic_dep, gapic_end, eol_warning_starts): + """Test all outcomes for each tracked version using parametrization.""" + mock_py_v = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) + + with patch('firebase_admin._python_version_support.sys.version_info', mock_py_v): + # Supported versions should not issue warnings + if expected_status == PythonVersionStatus.PYTHON_VERSION_SUPPORTED: + with warnings.catch_warnings(record=True) as recorded_warnings: + warnings.simplefilter('always') + result = check_python_version(today=mock_date) + assert len(recorded_warnings) == 0 + # All other statuses should issue a FutureWarning mentioning firebase-admin + else: + with pytest.warns(FutureWarning) as record: + result = check_python_version(today=mock_date) + assert len(record) == 1 + assert 'firebase-admin' in str(record[0].message) + + if result != expected_status: # pragma: NO COVER + py_version_str = f'{version_tuple[0]}.{version_tuple[1]}' + version_info = PYTHON_VERSION_INFO[version_tuple] + + fail_msg = _create_failure_message( + expected_status, + result, + py_version_str, + mock_date, + gapic_dep, + version_info.python_eol, + eol_warning_starts, + gapic_end, + ) + pytest.fail(fail_msg, pytrace=False) + + +@pytest.mark.parametrize('version_tuple', [(3, 9), (3, 10)]) +def test_python_39_and_310_emit_unsupported_warning_past_eol(version_tuple): + """Verify 3.9 and 3.10 emit PYTHON_VERSION_UNSUPPORTED after EOL + 1-week grace.""" + mock_py_version = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) + check_date = datetime.date(2026, 10, 20) + target = 'firebase_admin._python_version_support.sys.version_info' + + with patch(target, mock_py_version): + with pytest.warns(FutureWarning) as record: + result = check_python_version(today=check_date) + + assert result == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + assert len(record) == 1 + message = str(record[0].message) + assert 'non-supported Python version' in message + assert 'firebase-admin' in message + assert 'at least Python 3.11' in message + + +def test_override_gapic_end_only(): + """Test behavior when only gapic_end is manually overridden.""" + version_tuple = (3, 10) + original_info = PYTHON_VERSION_INFO[version_tuple] + mock_py_version = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) + + custom_gapic_end = original_info.python_eol + datetime.timedelta(days=212) + overridden_info = original_info._replace(gapic_end=custom_gapic_end) + version_info_target = 'firebase_admin._python_version_support.sys.version_info' + info_dict_target = 'firebase_admin._python_version_support.PYTHON_VERSION_INFO' + + with patch(version_info_target, mock_py_version): + with patch.dict(info_dict_target, {version_tuple: overridden_info}): + with pytest.warns(FutureWarning, match='past its end of life'): + result_before_boundary = check_python_version( + today=custom_gapic_end + datetime.timedelta(days=-1) + ) + assert result_before_boundary == PythonVersionStatus.PYTHON_VERSION_EOL + + with pytest.warns(FutureWarning, match='past its end of life'): + result_at_boundary = check_python_version(today=custom_gapic_end) + assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_EOL + + with pytest.warns(FutureWarning, match='non-supported Python version'): + result_after_boundary = check_python_version( + today=custom_gapic_end + datetime.timedelta(days=1) + ) + assert ( + result_after_boundary == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + ) + + +def test_override_gapic_deprecation_only(): + """Test behavior when only gapic_deprecation is manually overridden.""" + version_tuple = (3, 10) + original_info = PYTHON_VERSION_INFO[version_tuple] + mock_py_version = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) + + custom_gapic_dep = original_info.python_eol - datetime.timedelta(days=120) + overridden_info = original_info._replace(gapic_deprecation=custom_gapic_dep) + version_info_target = 'firebase_admin._python_version_support.sys.version_info' + info_dict_target = 'firebase_admin._python_version_support.PYTHON_VERSION_INFO' + + with patch(version_info_target, mock_py_version): + with patch.dict(info_dict_target, {version_tuple: overridden_info}): + result_before_boundary = check_python_version( + today=custom_gapic_dep - datetime.timedelta(days=1) + ) + assert ( + result_before_boundary == PythonVersionStatus.PYTHON_VERSION_SUPPORTED + ) + + with pytest.warns(FutureWarning, match='Google will stop supporting'): + result_at_boundary = check_python_version(today=custom_gapic_dep) + assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_DEPRECATED + + +def test_untracked_older_version_is_unsupported(): + """Test that an old, untracked version is unsupported and logs.""" + mock_py_version = VersionInfoMock(major=3, minor=8) + target = 'firebase_admin._python_version_support.sys.version_info' + + with patch(target, mock_py_version): + with pytest.warns(FutureWarning) as record: + mock_date = datetime.date(2025, 1, 15) + result = check_python_version(today=mock_date) + + assert result == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + assert len(record) == 1 + assert 'non-supported' in str(record[0].message) + + +def test_untracked_newer_version_is_supported(): + """Test that a new, untracked version is supported and does not log.""" + target = 'firebase_admin._python_version_support.sys.version_info' + for major_minor in [(3, 15), (40, 0)]: + mock_py_version = VersionInfoMock(major=major_minor[0], minor=major_minor[1]) + + with patch(target, mock_py_version): + with warnings.catch_warnings(record=True) as recorded_warnings: + warnings.simplefilter('always') + mock_date = datetime.date(2025, 1, 15) + result = check_python_version(today=mock_date) + + assert result == PythonVersionStatus.PYTHON_VERSION_SUPPORTED + assert len(recorded_warnings) == 0 + + +def test_package_label_resolution(): + """Test _get_distribution_and_import_packages with direct and mapped package names.""" + _cached_packages_distributions.cache_clear() + target = 'firebase_admin._python_version_support._cached_packages_distributions' + with patch(target, return_value={'firebase_admin': ['firebase-admin']}): + label_direct, dist_direct = _get_distribution_and_import_packages('firebase-admin') + assert label_direct == 'firebase-admin' + assert dist_direct is None + + label_mapped, dist_mapped = _get_distribution_and_import_packages('firebase_admin') + assert label_mapped == 'package firebase-admin (firebase_admin)' + assert dist_mapped == 'firebase-admin' From 45a5e29a19e6eea901d5c774ecfc8f02fcf7692b Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 21 Oct 2026 18:55:11 +0000 Subject: [PATCH 2/3] fix(core): simplify Python version support check and fix EoL window Remove dynamic package metadata lookup and unused GAPIC fields from VersionInfo, and start the EoL warning window on python_eol. --- firebase_admin/__init__.py | 2 +- firebase_admin/_python_version_support.py | 120 +++++----------------- tests/test_python_version_support.py | 102 +++++++----------- 3 files changed, 67 insertions(+), 157 deletions(-) diff --git a/firebase_admin/__init__.py b/firebase_admin/__init__.py index 862f2246..1b93f3c3 100644 --- a/firebase_admin/__init__.py +++ b/firebase_admin/__init__.py @@ -26,7 +26,7 @@ # Perform Python version check and emit a support warning if needed. -_python_version_support.check_python_version(package='firebase-admin') +_python_version_support.check_python_version() _apps = {} diff --git a/firebase_admin/_python_version_support.py b/firebase_admin/_python_version_support.py index 5e1b7b8c..9abbd08f 100644 --- a/firebase_admin/_python_version_support.py +++ b/firebase_admin/_python_version_support.py @@ -12,98 +12,80 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Code to check Python versions supported by the Firebase Admin Python SDK.""" +"""Code to check Python versions supported by the Firebase Admin Python SDK. + +Adapted from ``google.api_core._python_version_support``. +""" import datetime import enum -import functools -from importlib import metadata -import logging import sys import textwrap -from typing import Any, Dict, List, NamedTuple, Optional, Tuple +from typing import Dict, List, NamedTuple, Optional, Tuple import warnings -_LOGGER = logging.getLogger(__name__) - class PythonVersionStatus(enum.Enum): - """Support status of a Python version in this client library artifact release. - - "Support", in this context, means that this release of a client library - artifact is configured to run on the currently configured version of - Python. - """ - - PYTHON_VERSION_STATUS_UNSPECIFIED = 'PYTHON_VERSION_STATUS_UNSPECIFIED' + """Support status of a Python version in this SDK release.""" PYTHON_VERSION_SUPPORTED = 'PYTHON_VERSION_SUPPORTED' - """This Python version is fully supported, so the artifact running on this + """This Python version is fully supported, so the SDK running on this version will have all features and bug fixes.""" PYTHON_VERSION_DEPRECATED = 'PYTHON_VERSION_DEPRECATED' """This Python version is still supported, but support will end within a - year. At that time, there will be no more releases for this artifact + year. At that time, there will be no more releases for this SDK running under this Python version.""" PYTHON_VERSION_EOL = 'PYTHON_VERSION_EOL' """This Python version has reached its end of life in the Python community - (see https://devguide.python.org/versions/), and this artifact will cease + (see https://devguide.python.org/versions/), and this SDK will cease supporting this Python version within the next few releases.""" PYTHON_VERSION_UNSUPPORTED = 'PYTHON_VERSION_UNSUPPORTED' - """This release of the client library artifact may not be the latest, since - current releases no longer support this Python version.""" + """This release of the SDK may not be the latest, since current releases no + longer support this Python version.""" class VersionInfo(NamedTuple): """Hold release and support date information for a Python version.""" version: str - python_beta: Optional[datetime.date] python_start: datetime.date python_eol: datetime.date - gapic_start: Optional[datetime.date] = None # unused - gapic_deprecation: Optional[datetime.date] = None - gapic_end: Optional[datetime.date] = None - dep_unpatchable_cve: Optional[datetime.date] = None # unused + deprecation_date: Optional[datetime.date] = None + end_of_support_date: Optional[datetime.date] = None PYTHON_VERSIONS: List[VersionInfo] = [ # Refer to https://devguide.python.org/versions/ and the PEPs linked therefrom. VersionInfo( version='3.9', - python_beta=datetime.date(2020, 5, 18), python_start=datetime.date(2020, 10, 5), python_eol=datetime.date(2025, 10, 5), ), VersionInfo( version='3.10', - python_beta=datetime.date(2021, 5, 3), python_start=datetime.date(2021, 10, 4), python_eol=datetime.date(2026, 10, 4), ), VersionInfo( version='3.11', - python_beta=datetime.date(2022, 5, 8), python_start=datetime.date(2022, 10, 24), python_eol=datetime.date(2027, 10, 24), ), VersionInfo( version='3.12', - python_beta=datetime.date(2023, 5, 22), python_start=datetime.date(2023, 10, 2), python_eol=datetime.date(2028, 10, 2), ), VersionInfo( version='3.13', - python_beta=datetime.date(2024, 5, 8), python_start=datetime.date(2024, 10, 7), python_eol=datetime.date(2029, 10, 7), ), VersionInfo( version='3.14', - python_beta=datetime.date(2025, 5, 7), python_start=datetime.date(2025, 10, 7), python_eol=datetime.date(2030, 10, 7), ), @@ -118,19 +100,18 @@ class VersionInfo(NamedTuple): _FAKE_PAST_DATE = datetime.date.min + datetime.timedelta(days=900) _FAKE_PAST_VERSION = VersionInfo( version='0.0', - python_beta=_FAKE_PAST_DATE, python_start=_FAKE_PAST_DATE, python_eol=_FAKE_PAST_DATE, ) _FAKE_FUTURE_DATE = datetime.date.max - datetime.timedelta(days=900) _FAKE_FUTURE_VERSION = VersionInfo( version='999.0', - python_beta=_FAKE_FUTURE_DATE, python_start=_FAKE_FUTURE_DATE, python_eol=_FAKE_FUTURE_DATE, ) DEPRECATION_WARNING_PERIOD = datetime.timedelta(days=365) EOL_GRACE_PERIOD = datetime.timedelta(weeks=1) +_PACKAGE_LABEL = 'package firebase-admin (firebase_admin)' def _flatten_message(text: str) -> str: @@ -138,50 +119,11 @@ def _flatten_message(text: str) -> str: return ' '.join(textwrap.dedent(text).strip().split()) -@functools.cache -def _cached_packages_distributions(): - # `importlib.metadata.packages_distributions()` is only available in Python 3.10+. - packages_distributions = getattr(metadata, 'packages_distributions', None) - if packages_distributions is None: # pragma: NO COVER - return {} - return packages_distributions() - - -def _get_pypi_package_name(module_name: str) -> Optional[str]: - """Determine the PyPI package name for a given module name.""" - try: - module_to_distributions = _cached_packages_distributions() - - if module_name in module_to_distributions: # pragma: NO COVER - return module_to_distributions[module_name][0] - except Exception as err: # pragma: NO COVER # pylint: disable=broad-except - _LOGGER.info( - 'An error occurred while determining PyPI package name for %s: %s', - module_name, - err, - ) - - return None - - -def _get_distribution_and_import_packages(import_package: str) -> Tuple[str, Any]: - """Return a pretty string with distribution & import package names.""" - distribution_package = _get_pypi_package_name(import_package) - pretty_package_name = ( - f'package {distribution_package} ({import_package})' - if distribution_package - else import_package - ) - return pretty_package_name, distribution_package - - def check_python_version( - package: str = 'firebase-admin', today: Optional[datetime.date] = None) -> PythonVersionStatus: """Check the running Python version and issue a support warning if needed. Args: - package: The name of the package to display in the warning message. today: The date to check against. Defaults to the current date. Returns: @@ -201,10 +143,12 @@ def check_python_version( else: version_info = _FAKE_FUTURE_VERSION - gapic_deprecation = version_info.gapic_deprecation or ( + deprecation_date = version_info.deprecation_date or ( version_info.python_eol - DEPRECATION_WARNING_PERIOD ) - gapic_end = version_info.gapic_end or (version_info.python_eol + EOL_GRACE_PERIOD) + end_of_support_date = version_info.end_of_support_date or ( + version_info.python_eol + EOL_GRACE_PERIOD + ) def min_python(date: datetime.date) -> str: """Find the minimum supported Python version for a given date.""" @@ -213,52 +157,42 @@ def min_python(date: datetime.date) -> str: return f'{version[0]}.{version[1]}' return 'at a currently supported version [https://devguide.python.org/versions]' - # Resolve the pretty package label lazily so we avoid any work on - # the happy path (supported Python version, no warning needed). - def get_package_label(): - label, _ = _get_distribution_and_import_packages(package) - return label - - if gapic_end < today: - package_label = get_package_label() + if end_of_support_date < today: message = _flatten_message( f""" You are using a non-supported Python version ({py_version_str}). - Google will not post any further updates to {package_label} + Google will not post any further updates to {_PACKAGE_LABEL} supporting this Python version. Please upgrade to the latest Python version, or at least Python {min_python(today)}, and then update - {package_label}. + {_PACKAGE_LABEL}. """ ) warnings.warn(message, FutureWarning) return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED - eol_date = version_info.python_eol + EOL_GRACE_PERIOD - if eol_date <= today <= gapic_end: - package_label = get_package_label() + if version_info.python_eol <= today <= end_of_support_date: message = _flatten_message( f""" You are using a Python version ({py_version_str}) - past its end of life. Google will update {package_label} + past its end of life. Google will update {_PACKAGE_LABEL} with critical bug fixes on a best-effort basis, but not with any other fixes or features. Please upgrade to the latest Python version, or at least Python - {min_python(today)}, and then update {package_label}. + {min_python(today)}, and then update {_PACKAGE_LABEL}. """ ) warnings.warn(message, FutureWarning) return PythonVersionStatus.PYTHON_VERSION_EOL - if gapic_deprecation <= today <= gapic_end: - package_label = get_package_label() + if deprecation_date <= today < version_info.python_eol: message = _flatten_message( f""" You are using a Python version ({py_version_str}) which Google will - stop supporting in new releases of {package_label} once it reaches + stop supporting in new releases of {_PACKAGE_LABEL} once it reaches its end of life ({version_info.python_eol}). Please upgrade to the latest Python version, or at least Python {min_python(version_info.python_eol)}, to continue receiving updates - for {package_label} past that date. + for {_PACKAGE_LABEL} past that date. """ ) warnings.warn(message, FutureWarning) diff --git a/tests/test_python_version_support.py b/tests/test_python_version_support.py index b9f81116..42aafbb2 100644 --- a/tests/test_python_version_support.py +++ b/tests/test_python_version_support.py @@ -25,9 +25,7 @@ from firebase_admin._python_version_support import ( PYTHON_VERSION_INFO, PythonVersionStatus, - _cached_packages_distributions, _flatten_message, - _get_distribution_and_import_packages, check_python_version, ) @@ -47,7 +45,7 @@ def test_flatten_message(): def _create_failure_message( - expected, result, py_version, date, gapic_dep, py_eol, eol_warn, gapic_end): + expected, result, py_version, date, deprecation_date, py_eol, end_of_support_date): """Create a detailed failure message for a test.""" return textwrap.dedent( # pragma: NO COVER f""" @@ -56,13 +54,12 @@ def _create_failure_message( Received status: {result.name} --------------------- Context: - - Mocked Python Version: {py_version} - - Mocked Today's Date: {date} + - Mocked Python Version: {py_version} + - Mocked Today's Date: {date} Calculated Dates: - - gapic_deprecation: {gapic_dep} - - python_eol: {py_eol} - - eol_warning_starts: {eol_warn} - - gapic_end: {gapic_end} + - deprecation_date: {deprecation_date} + - python_eol: {py_eol} + - end_of_support_date: {end_of_support_date} """ ) @@ -73,41 +70,36 @@ def get_tracked_version_test_cases(): for version_tuple, version_info in PYTHON_VERSION_INFO.items(): py_version_str = f'{version_tuple[0]}.{version_tuple[1]}' - gapic_dep = version_info.gapic_deprecation or ( + deprecation_date = version_info.deprecation_date or ( version_info.python_eol - datetime.timedelta(days=365) ) - gapic_end = version_info.gapic_end or ( + end_of_support_date = version_info.end_of_support_date or ( version_info.python_eol + datetime.timedelta(weeks=1) ) - eol_warning_starts = version_info.python_eol + datetime.timedelta(weeks=1) test_cases = { 'supported_before_deprecation_date': { - 'date': gapic_dep - datetime.timedelta(days=1), + 'date': deprecation_date - datetime.timedelta(days=1), 'expected': PythonVersionStatus.PYTHON_VERSION_SUPPORTED, }, 'deprecated_on_deprecation_date': { - 'date': gapic_dep, + 'date': deprecation_date, 'expected': PythonVersionStatus.PYTHON_VERSION_DEPRECATED, }, - 'deprecated_on_eol_date': { - 'date': version_info.python_eol, - 'expected': PythonVersionStatus.PYTHON_VERSION_DEPRECATED, - }, - 'deprecated_before_eol_warning_starts': { - 'date': eol_warning_starts - datetime.timedelta(days=1), + 'deprecated_before_eol_date': { + 'date': version_info.python_eol - datetime.timedelta(days=1), 'expected': PythonVersionStatus.PYTHON_VERSION_DEPRECATED, }, - 'eol_on_eol_warning_date': { - 'date': eol_warning_starts, + 'eol_on_eol_date': { + 'date': version_info.python_eol, 'expected': PythonVersionStatus.PYTHON_VERSION_EOL, }, - 'eol_on_gapic_end_date': { - 'date': gapic_end, + 'eol_on_end_of_support_date': { + 'date': end_of_support_date, 'expected': PythonVersionStatus.PYTHON_VERSION_EOL, }, - 'unsupported_after_gapic_end_date': { - 'date': gapic_end + datetime.timedelta(days=1), + 'unsupported_after_end_of_support_date': { + 'date': end_of_support_date + datetime.timedelta(days=1), 'expected': PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED, }, } @@ -118,9 +110,8 @@ def get_tracked_version_test_cases(): version_tuple, params['date'], params['expected'], - gapic_dep, - gapic_end, - eol_warning_starts, + deprecation_date, + end_of_support_date, id=f'{py_version_str}-{name}', ) ) @@ -128,11 +119,11 @@ def get_tracked_version_test_cases(): @pytest.mark.parametrize( - 'version_tuple, mock_date, expected_status, gapic_dep, gapic_end, eol_warning_starts', + 'version_tuple, mock_date, expected_status, deprecation_date, end_of_support_date', get_tracked_version_test_cases(), ) def test_all_tracked_versions_and_date_scenarios( - version_tuple, mock_date, expected_status, gapic_dep, gapic_end, eol_warning_starts): + version_tuple, mock_date, expected_status, deprecation_date, end_of_support_date): """Test all outcomes for each tracked version using parametrization.""" mock_py_v = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) @@ -148,7 +139,7 @@ def test_all_tracked_versions_and_date_scenarios( with pytest.warns(FutureWarning) as record: result = check_python_version(today=mock_date) assert len(record) == 1 - assert 'firebase-admin' in str(record[0].message) + assert 'package firebase-admin (firebase_admin)' in str(record[0].message) if result != expected_status: # pragma: NO COVER py_version_str = f'{version_tuple[0]}.{version_tuple[1]}' @@ -159,10 +150,9 @@ def test_all_tracked_versions_and_date_scenarios( result, py_version_str, mock_date, - gapic_dep, + deprecation_date, version_info.python_eol, - eol_warning_starts, - gapic_end, + end_of_support_date, ) pytest.fail(fail_msg, pytrace=False) @@ -182,18 +172,18 @@ def test_python_39_and_310_emit_unsupported_warning_past_eol(version_tuple): assert len(record) == 1 message = str(record[0].message) assert 'non-supported Python version' in message - assert 'firebase-admin' in message + assert 'package firebase-admin (firebase_admin)' in message assert 'at least Python 3.11' in message -def test_override_gapic_end_only(): - """Test behavior when only gapic_end is manually overridden.""" +def test_override_end_of_support_date_only(): + """Test behavior when only end_of_support_date is manually overridden.""" version_tuple = (3, 10) original_info = PYTHON_VERSION_INFO[version_tuple] mock_py_version = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) - custom_gapic_end = original_info.python_eol + datetime.timedelta(days=212) - overridden_info = original_info._replace(gapic_end=custom_gapic_end) + custom_end = original_info.python_eol + datetime.timedelta(days=212) + overridden_info = original_info._replace(end_of_support_date=custom_end) version_info_target = 'firebase_admin._python_version_support.sys.version_info' info_dict_target = 'firebase_admin._python_version_support.PYTHON_VERSION_INFO' @@ -201,45 +191,45 @@ def test_override_gapic_end_only(): with patch.dict(info_dict_target, {version_tuple: overridden_info}): with pytest.warns(FutureWarning, match='past its end of life'): result_before_boundary = check_python_version( - today=custom_gapic_end + datetime.timedelta(days=-1) + today=custom_end + datetime.timedelta(days=-1) ) assert result_before_boundary == PythonVersionStatus.PYTHON_VERSION_EOL with pytest.warns(FutureWarning, match='past its end of life'): - result_at_boundary = check_python_version(today=custom_gapic_end) + result_at_boundary = check_python_version(today=custom_end) assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_EOL with pytest.warns(FutureWarning, match='non-supported Python version'): result_after_boundary = check_python_version( - today=custom_gapic_end + datetime.timedelta(days=1) + today=custom_end + datetime.timedelta(days=1) ) assert ( result_after_boundary == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED ) -def test_override_gapic_deprecation_only(): - """Test behavior when only gapic_deprecation is manually overridden.""" +def test_override_deprecation_date_only(): + """Test behavior when only deprecation_date is manually overridden.""" version_tuple = (3, 10) original_info = PYTHON_VERSION_INFO[version_tuple] mock_py_version = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) - custom_gapic_dep = original_info.python_eol - datetime.timedelta(days=120) - overridden_info = original_info._replace(gapic_deprecation=custom_gapic_dep) + custom_dep = original_info.python_eol - datetime.timedelta(days=120) + overridden_info = original_info._replace(deprecation_date=custom_dep) version_info_target = 'firebase_admin._python_version_support.sys.version_info' info_dict_target = 'firebase_admin._python_version_support.PYTHON_VERSION_INFO' with patch(version_info_target, mock_py_version): with patch.dict(info_dict_target, {version_tuple: overridden_info}): result_before_boundary = check_python_version( - today=custom_gapic_dep - datetime.timedelta(days=1) + today=custom_dep - datetime.timedelta(days=1) ) assert ( result_before_boundary == PythonVersionStatus.PYTHON_VERSION_SUPPORTED ) with pytest.warns(FutureWarning, match='Google will stop supporting'): - result_at_boundary = check_python_version(today=custom_gapic_dep) + result_at_boundary = check_python_version(today=custom_dep) assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_DEPRECATED @@ -272,17 +262,3 @@ def test_untracked_newer_version_is_supported(): assert result == PythonVersionStatus.PYTHON_VERSION_SUPPORTED assert len(recorded_warnings) == 0 - - -def test_package_label_resolution(): - """Test _get_distribution_and_import_packages with direct and mapped package names.""" - _cached_packages_distributions.cache_clear() - target = 'firebase_admin._python_version_support._cached_packages_distributions' - with patch(target, return_value={'firebase_admin': ['firebase-admin']}): - label_direct, dist_direct = _get_distribution_and_import_packages('firebase-admin') - assert label_direct == 'firebase-admin' - assert dist_direct is None - - label_mapped, dist_mapped = _get_distribution_and_import_packages('firebase_admin') - assert label_mapped == 'package firebase-admin (firebase_admin)' - assert dist_mapped == 'firebase-admin' From 38c29ea7f6b0790d7b0d5ffa110f6187a6315a93 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 21 Oct 2026 20:23:00 +0000 Subject: [PATCH 3/3] fix(core): set warning stacklevel and refine fallback version message Add stacklevel=2 to FutureWarning calls in check_python_version and include the 'Python' prefix in min_python() so the fallback wording reads naturally. --- firebase_admin/_python_version_support.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/firebase_admin/_python_version_support.py b/firebase_admin/_python_version_support.py index 9abbd08f..65ef30a3 100644 --- a/firebase_admin/_python_version_support.py +++ b/firebase_admin/_python_version_support.py @@ -154,8 +154,8 @@ def min_python(date: datetime.date) -> str: """Find the minimum supported Python version for a given date.""" for version, info in sorted(PYTHON_VERSION_INFO.items()): if info.python_start <= date < info.python_eol: - return f'{version[0]}.{version[1]}' - return 'at a currently supported version [https://devguide.python.org/versions]' + return f'Python {version[0]}.{version[1]}' + return 'a currently supported Python version [https://devguide.python.org/versions]' if end_of_support_date < today: message = _flatten_message( @@ -163,11 +163,11 @@ def min_python(date: datetime.date) -> str: You are using a non-supported Python version ({py_version_str}). Google will not post any further updates to {_PACKAGE_LABEL} supporting this Python version. Please upgrade to the latest Python - version, or at least Python {min_python(today)}, and then update + version, or at least {min_python(today)}, and then update {_PACKAGE_LABEL}. """ ) - warnings.warn(message, FutureWarning) + warnings.warn(message, FutureWarning, stacklevel=2) return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED if version_info.python_eol <= today <= end_of_support_date: @@ -177,11 +177,11 @@ def min_python(date: datetime.date) -> str: past its end of life. Google will update {_PACKAGE_LABEL} with critical bug fixes on a best-effort basis, but not with any other fixes or features. Please upgrade - to the latest Python version, or at least Python + to the latest Python version, or at least {min_python(today)}, and then update {_PACKAGE_LABEL}. """ ) - warnings.warn(message, FutureWarning) + warnings.warn(message, FutureWarning, stacklevel=2) return PythonVersionStatus.PYTHON_VERSION_EOL if deprecation_date <= today < version_info.python_eol: @@ -190,12 +190,12 @@ def min_python(date: datetime.date) -> str: You are using a Python version ({py_version_str}) which Google will stop supporting in new releases of {_PACKAGE_LABEL} once it reaches its end of life ({version_info.python_eol}). Please upgrade to the - latest Python version, or at least Python + latest Python version, or at least {min_python(version_info.python_eol)}, to continue receiving updates for {_PACKAGE_LABEL} past that date. """ ) - warnings.warn(message, FutureWarning) + warnings.warn(message, FutureWarning, stacklevel=2) return PythonVersionStatus.PYTHON_VERSION_DEPRECATED return PythonVersionStatus.PYTHON_VERSION_SUPPORTED