1# Copyright 2025 Google LLC 
    2# 
    3# Licensed under the Apache License, Version 2.0 (the "License"); 
    4# you may not use this file except in compliance with the License. 
    5# You may obtain a copy of the License at 
    6# 
    7#     http://www.apache.org/licenses/LICENSE-2.0 
    8# 
    9# Unless required by applicable law or agreed to in writing, software 
    10# distributed under the License is distributed on an "AS IS" BASIS, 
    11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
    12# See the License for the specific language governing permissions and 
    13# limitations under the License. 
    14 
    15"""Code to check Python versions supported by Google Cloud Client Libraries.""" 
    16 
    17import datetime 
    18import enum 
    19import warnings 
    20import sys 
    21import textwrap 
    22from typing import Any, List, NamedTuple, Optional, Dict, Tuple 
    23 
    24 
    25class PythonVersionStatus(enum.Enum): 
    26    """Support status of a Python version in this client library artifact release. 
    27 
    28    "Support", in this context, means that this release of a client library 
    29    artifact is configured to run on the currently configured version of 
    30    Python. 
    31    """ 
    32 
    33    PYTHON_VERSION_STATUS_UNSPECIFIED = "PYTHON_VERSION_STATUS_UNSPECIFIED" 
    34 
    35    PYTHON_VERSION_SUPPORTED = "PYTHON_VERSION_SUPPORTED" 
    36    """This Python version is fully supported, so the artifact running on this 
    37    version will have all features and bug fixes.""" 
    38 
    39    PYTHON_VERSION_DEPRECATED = "PYTHON_VERSION_DEPRECATED" 
    40    """This Python version is still supported, but support will end within a 
    41    year. At that time, there will be no more releases for this artifact 
    42    running under this Python version.""" 
    43 
    44    PYTHON_VERSION_EOL = "PYTHON_VERSION_EOL" 
    45    """This Python version has reached its end of life in the Python community 
    46    (see https://devguide.python.org/versions/), and this artifact will cease 
    47    supporting this Python version within the next few releases.""" 
    48 
    49    PYTHON_VERSION_UNSUPPORTED = "PYTHON_VERSION_UNSUPPORTED" 
    50    """This release of the client library artifact may not be the latest, since 
    51    current releases no longer support this Python version.""" 
    52 
    53 
    54class VersionInfo(NamedTuple): 
    55    """Hold release and support date information for a Python version.""" 
    56 
    57    version: str 
    58    python_beta: Optional[datetime.date] 
    59    python_start: datetime.date 
    60    python_eol: datetime.date 
    61    gapic_start: Optional[datetime.date] = None  # unused 
    62    gapic_deprecation: Optional[datetime.date] = None 
    63    gapic_end: Optional[datetime.date] = None 
    64    dep_unpatchable_cve: Optional[datetime.date] = None  # unused 
    65 
    66 
    67PYTHON_VERSIONS: List[VersionInfo] = [ 
    68    # Refer to https://devguide.python.org/versions/ and the PEPs linked therefrom. 
    69    VersionInfo( 
    70        version="3.7", 
    71        python_beta=None, 
    72        python_start=datetime.date(2018, 6, 27), 
    73        python_eol=datetime.date(2023, 6, 27), 
    74    ), 
    75    VersionInfo( 
    76        version="3.8", 
    77        python_beta=None, 
    78        python_start=datetime.date(2019, 10, 14), 
    79        python_eol=datetime.date(2024, 10, 7), 
    80    ), 
    81    VersionInfo( 
    82        version="3.9", 
    83        python_beta=datetime.date(2020, 5, 18), 
    84        python_start=datetime.date(2020, 10, 5), 
    85        python_eol=datetime.date(2025, 10, 5), 
    86        gapic_end=datetime.date(2025, 10, 5) + datetime.timedelta(days=90), 
    87    ), 
    88    VersionInfo( 
    89        version="3.10", 
    90        python_beta=datetime.date(2021, 5, 3), 
    91        python_start=datetime.date(2021, 10, 4), 
    92        python_eol=datetime.date(2026, 10, 4),  # TODO: specify day when announced 
    93    ), 
    94    VersionInfo( 
    95        version="3.11", 
    96        python_beta=datetime.date(2022, 5, 8), 
    97        python_start=datetime.date(2022, 10, 24), 
    98        python_eol=datetime.date(2027, 10, 24),  # TODO: specify day when announced 
    99    ), 
    100    VersionInfo( 
    101        version="3.12", 
    102        python_beta=datetime.date(2023, 5, 22), 
    103        python_start=datetime.date(2023, 10, 2), 
    104        python_eol=datetime.date(2028, 10, 2),  # TODO: specify day when announced 
    105    ), 
    106    VersionInfo( 
    107        version="3.13", 
    108        python_beta=datetime.date(2024, 5, 8), 
    109        python_start=datetime.date(2024, 10, 7), 
    110        python_eol=datetime.date(2029, 10, 7),  # TODO: specify day when announced 
    111    ), 
    112    VersionInfo( 
    113        version="3.14", 
    114        python_beta=datetime.date(2025, 5, 7), 
    115        python_start=datetime.date(2025, 10, 7), 
    116        python_eol=datetime.date(2030, 10, 7),  # TODO: specify day when announced 
    117    ), 
    118] 
    119 
    120PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = {} 
    121for info in PYTHON_VERSIONS: 
    122    major, minor = map(int, info.version.split(".")) 
    123    PYTHON_VERSION_INFO[(major, minor)] = info 
    124 
    125 
    126LOWEST_TRACKED_VERSION = min(PYTHON_VERSION_INFO.keys()) 
    127_FAKE_PAST_DATE = datetime.date.min + datetime.timedelta(days=900) 
    128_FAKE_PAST_VERSION = VersionInfo( 
    129    version="0.0", 
    130    python_beta=_FAKE_PAST_DATE, 
    131    python_start=_FAKE_PAST_DATE, 
    132    python_eol=_FAKE_PAST_DATE, 
    133) 
    134_FAKE_FUTURE_DATE = datetime.date.max - datetime.timedelta(days=900) 
    135_FAKE_FUTURE_VERSION = VersionInfo( 
    136    version="999.0", 
    137    python_beta=_FAKE_FUTURE_DATE, 
    138    python_start=_FAKE_FUTURE_DATE, 
    139    python_eol=_FAKE_FUTURE_DATE, 
    140) 
    141DEPRECATION_WARNING_PERIOD = datetime.timedelta(days=365) 
    142EOL_GRACE_PERIOD = datetime.timedelta(weeks=1) 
    143 
    144 
    145def _flatten_message(text: str) -> str: 
    146    """Dedent a multi-line string and flatten it into a single line.""" 
    147    return " ".join(textwrap.dedent(text).strip().split()) 
    148 
    149 
    150# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove once we 
    151# no longer support Python 3.7 
    152if sys.version_info < (3, 8): 
    153 
    154    def _get_pypi_package_name(module_name):  # pragma: NO COVER 
    155        """Determine the PyPI package name for a given module name.""" 
    156        return None 
    157 
    158else: 
    159    from importlib import metadata 
    160 
    161    def _get_pypi_package_name(module_name): 
    162        """Determine the PyPI package name for a given module name.""" 
    163        try: 
    164            # Get the mapping of modules to distributions 
    165            module_to_distributions = metadata.packages_distributions() 
    166 
    167            # Check if the module is found in the mapping 
    168            if module_name in module_to_distributions:  # pragma: NO COVER 
    169                # The value is a list of distribution names, take the first one 
    170                return module_to_distributions[module_name][0] 
    171            else: 
    172                return None  # Module not found in the mapping 
    173        except Exception as e: 
    174            print(f"An error occurred: {e}") 
    175            return None 
    176 
    177 
    178def _get_distribution_and_import_packages(import_package: str) -> Tuple[str, Any]: 
    179    """Return a pretty string with distribution & import package names.""" 
    180    distribution_package = _get_pypi_package_name(import_package) 
    181    dependency_distribution_and_import_packages = ( 
    182        f"package {distribution_package} ({import_package})" 
    183        if distribution_package 
    184        else import_package 
    185    ) 
    186    return dependency_distribution_and_import_packages, distribution_package 
    187 
    188 
    189def check_python_version( 
    190    package: str = "this package", today: Optional[datetime.date] = None 
    191) -> PythonVersionStatus: 
    192    """Check the running Python version and issue a support warning if needed. 
    193 
    194    Args: 
    195        today: The date to check against. Defaults to the current date. 
    196 
    197    Returns: 
    198        The support status of the current Python version. 
    199    """ 
    200    today = today or datetime.date.today() 
    201    package_label, _ = _get_distribution_and_import_packages(package) 
    202 
    203    python_version = sys.version_info 
    204    version_tuple = (python_version.major, python_version.minor) 
    205    py_version_str = sys.version.split()[0] 
    206 
    207    version_info = PYTHON_VERSION_INFO.get(version_tuple) 
    208 
    209    if not version_info: 
    210        if version_tuple < LOWEST_TRACKED_VERSION: 
    211            version_info = _FAKE_PAST_VERSION 
    212        else: 
    213            version_info = _FAKE_FUTURE_VERSION 
    214 
    215    gapic_deprecation = version_info.gapic_deprecation or ( 
    216        version_info.python_eol - DEPRECATION_WARNING_PERIOD 
    217    ) 
    218    gapic_end = version_info.gapic_end or (version_info.python_eol + EOL_GRACE_PERIOD) 
    219 
    220    def min_python(date: datetime.date) -> str: 
    221        """Find the minimum supported Python version for a given date.""" 
    222        for version, info in sorted(PYTHON_VERSION_INFO.items()): 
    223            if info.python_start <= date < info.python_eol: 
    224                return f"{version[0]}.{version[1]}" 
    225        return "at a currently supported version [https://devguide.python.org/versions]" 
    226 
    227    if gapic_end < today: 
    228        message = _flatten_message( 
    229            f""" 
    230            You are using a non-supported Python version ({py_version_str}). 
    231            Google will not post any further updates to {package_label} 
    232            supporting this Python version. Please upgrade to the latest Python 
    233            version, or at least Python {min_python(today)}, and then update 
    234            {package_label}. 
    235            """ 
    236        ) 
    237        warnings.warn(message, FutureWarning) 
    238        return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED 
    239 
    240    eol_date = version_info.python_eol + EOL_GRACE_PERIOD 
    241    if eol_date <= today <= gapic_end: 
    242        message = _flatten_message( 
    243            f""" 
    244            You are using a Python version ({py_version_str}) 
    245            past its end of life. Google will update {package_label} 
    246            with critical bug fixes on a best-effort basis, but not 
    247            with any other fixes or features. Please upgrade 
    248            to the latest Python version, or at least Python 
    249            {min_python(today)}, and then update {package_label}. 
    250            """ 
    251        ) 
    252        warnings.warn(message, FutureWarning) 
    253        return PythonVersionStatus.PYTHON_VERSION_EOL 
    254 
    255    if gapic_deprecation <= today <= gapic_end: 
    256        message = _flatten_message( 
    257            f""" 
    258            You are using a Python version ({py_version_str}) which Google will 
    259            stop supporting in new releases of {package_label} once it reaches 
    260            its end of life ({version_info.python_eol}). Please upgrade to the 
    261            latest Python version, or at least Python 
    262            {min_python(version_info.python_eol)}, to continue receiving updates 
    263            for {package_label} past that date. 
    264            """ 
    265        ) 
    266        warnings.warn(message, FutureWarning) 
    267        return PythonVersionStatus.PYTHON_VERSION_DEPRECATED 
    268 
    269    return PythonVersionStatus.PYTHON_VERSION_SUPPORTED