Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
6316d70
feat: provide Python run-time version support
vchudnov-g Jul 29, 2025
a136ad7
feat: apply Python version suport warnings to api_core
vchudnov-g Jul 29, 2025
45cd647
feat: add deprecation check for the protobuf package
vchudnov-g Jul 30, 2025
25225fa
format files
vchudnov-g Jul 30, 2025
1d567c0
fix lint warning
vchudnov-g Jul 30, 2025
f1dbbb4
add docstring to `warn_deprecation_for_versions_less_than`
vchudnov-g Jul 30, 2025
e3fd56f
Add/fix docstrings
vchudnov-g Jul 30, 2025
8b1dfb1
fix typo
vchudnov-g Jul 31, 2025
4b9208e
add test for _python_package_support.py
vchudnov-g Jul 31, 2025
5cf8652
add constants for various buffer periods
vchudnov-g Jul 31, 2025
7d8b1c7
Update warning code to only require import package names
vchudnov-g Jul 31, 2025
cda8e27
Fix messaegs and test
vchudnov-g Jul 31, 2025
db92fac
Add TODO: provide the functionality in previous versions of api_core
vchudnov-g Jul 31, 2025
118a7c8
Fix mypy failures
vchudnov-g Aug 12, 2025
f1c46aa
Try to remove a round-off error causing a test mock failure
vchudnov-g Aug 12, 2025
474ec0d
Remove more potential test failures/warnings
vchudnov-g Aug 12, 2025
2083b49
Format
vchudnov-g Aug 12, 2025
4ea124e
Try making the specified_timeout a float
vchudnov-g Aug 12, 2025
c5949c4
fix: tweak message parameter names
vchudnov-g Sep 3, 2025
6fc1473
fix: add PYTHON_VERSION_STATUS_UNSPECIFIED enum value
vchudnov-g Sep 3, 2025
8829e20
docs: tweak TODOs
vchudnov-g Sep 3, 2025
bdb6260
fix test to match code changes
vchudnov-g Sep 3, 2025
7896664
fix: restore asyncio designator for async tests
vchudnov-g Sep 4, 2025
54a5611
Remove some workarounds trying to fix presubmit errors (since fixed)
vchudnov-g Sep 5, 2025
2e2990b
Additional test tweaks to prevent non-significant failures
vchudnov-g Sep 5, 2025
35a2074
chore: fix test that was waiting before initiating operation
vchudnov-g Sep 5, 2025
cd64832
fix: skip coverage checks for code specific to Pyton 3.7
vchudnov-g Sep 5, 2025
e36414a
chore: try to address coverage failures
vchudnov-g Sep 8, 2025
b6245ca
chore: try to address more coverage failures
vchudnov-g Sep 8, 2025
3a897ba
chore: fix comments and exported function
vchudnov-g Sep 9, 2025
814ea63
fix lint
vchudnov-g Sep 9, 2025
8dee04b
chore: fix unit test
vchudnov-g Sep 9, 2025
95f4777
chore: lint
vchudnov-g Sep 9, 2025
a34ec15
Use warnings module instead of logging module
vchudnov-g Sep 10, 2025
a04e2e6
fix: print the current package versions correctly
vchudnov-g Sep 15, 2025
8b3f337
wip: before gemini doc fixes
vchudnov-g Sep 15, 2025
29896cf
fix: return tuple of version tuple and version string, fix tests
vchudnov-g Sep 16, 2025
d421f98
lint
vchudnov-g Sep 22, 2025
ac76f7a
feat: add grace period for 3.9; tweak warning
vchudnov-g Oct 1, 2025
e093a96
lint
vchudnov-g Oct 1, 2025
187f848
allow providing a recommended version
vchudnov-g Oct 1, 2025
b575a16
refactor: used namedtuple
vchudnov-g Oct 13, 2025
ecb7211
refactor: s/"--"/UNKNOWN_VERSION_STRING/ in code, not test
vchudnov-g Oct 13, 2025
3c587cf
refactor s/next_supported_version/minimum_fully_supported_version/
vchudnov-g Oct 13, 2025
fbe0f0b
refactor: s/dependent_/consumer_/
vchudnov-g Oct 13, 2025
28b0b32
refactor: add _PACKAGE_DEPENDENCY_WARNINGS
vchudnov-g Oct 13, 2025
21c9aec
refactor: reorder and comment on _init__ statements for clarity
vchudnov-g Oct 13, 2025
e24c13d
fix: exapnd VersionInfo with version field; clarify fake dates
vchudnov-g Oct 13, 2025
dbc3a26
doc: document PythonVersionSupportStatus
vchudnov-g Oct 13, 2025
d472bae
refactor: make fake past and future versions into constants
vchudnov-g Oct 14, 2025
0550f83
refactor: reword default string for min_python
vchudnov-g Oct 14, 2025
491b695
fix: fix populating PYTHON_VERSION_INFO
vchudnov-g Oct 14, 2025
6338389
fix: type hints
vchudnov-g Oct 14, 2025
05bcf6f
feat: allow check_dependency_versions() to take any number of Depende…
vchudnov-g Oct 17, 2025
fc224b7
add unit test to satisfy coverage requirement
vchudnov-g Oct 17, 2025
a6c40ce
fix lint
vchudnov-g Oct 17, 2025
d0414b2
fix lint
vchudnov-g Oct 17, 2025
835df53
test: add test for _flatten_message
vchudnov-g Oct 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Update warning code to only require import package names
We automatically get the distribution package names under the hood.
  • Loading branch information
vchudnov-g committed Oct 23, 2025
commit 7d8b1c739d5b67ab7069cc0ea90bb5b41ac06a4b
4 changes: 2 additions & 2 deletions google/api_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
__version__ = api_core_version.__version__

check_python_version = _python_version_support.check_python_version
check_python_version(package="package google-api-core (google.api_core)")
check_python_version(package="google.api_core")

check_dependency_versions = _python_package_support.check_dependency_versions
check_dependency_versions("google-api-core (google.api_core)")
check_dependency_versions("google.api_core")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can this be re-arranged with comments?

# expose dependency checks for external callers
check_python_version = _python_version_support.check_python_version
check_dependency_versions = _python_package_support.check_dependency_versions
warn_deprecation_for_versions_less_than = (
    _python_package_support.warn_deprecation_for_versions_less_than
)

# perform version checks against api_core, and emit warnings if needed
check_python_version(package="google.api_core")
check_dependency_versions("google.api_core")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And maybe the check methods themselves should be renamed to make it clear that they emit a warning. If I saw it imported in an external package, that part might not be clear from the name. But maybe I'm overthinking it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did the rearrangement.

I'm not convinced about the renaming. I think "check" conveys the intent more than simply "warn", and "check _and_warn" is too verbose without a lot of benefit. Open to counterarguments..

72 changes: 46 additions & 26 deletions google/api_core/_python_package_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
import logging
import sys
from typing import Optional
from ._python_version_support import _flatten_message
from ._python_version_support import (
_flatten_message,
_get_distribution_and_import_packages,
)

# It is a good practice to alias the Version class for clarity in type hints.
from packaging.version import parse as parse_version, Version as PackagingVersion
Expand Down Expand Up @@ -57,73 +60,90 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]:


def warn_deprecation_for_versions_less_than(
dependent_package: str,
dependency_name: str,
dependent_import_package: str,
dependency_import_package: str,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it might be nice if these variables were more distinct. Maybe target_package?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

next_supported_version: str,
message_template: Optional[str] = None,
):
"""Issue a deprecation warning for outdated versions of `dependency_name`.
"""Issue any needed deprecation warnings for `dependency_import_package`.

If `dependency_name` is installed at a version less than
If `dependency_import_package` is installed at a version less than
`next_supported_versions`, this issues a warning using either a
default `message_template` or one provided by the user. The
default `message_template informs users that they will not receive
future updates `dependent_package` if `dependency_name` is somehow
pinned to a version lower than `next_supported_version`.
future updates `dependent_import_package` if
`dependency_import_package` is somehow pinned to a version lower
than `next_supported_version`.

Args:
dependent_package: The distribution name of the package that
needs `dependency_name`.
dependency_name: The distribution name oft he dependency to check.
dependent_import_package: The import name of the package that
needs `dependency_import_package`.
dependency_import_package: The import name of the dependency to check.
next_supported_version: The version number below which a deprecation
warning will be logged.
message_template: A custom default message template to replace
the default. This `message_template` is treated as an
f-string, where the following variables are defined:
`dependency_name`, `dependent_package`,
`next_supported_version`, and `version_used`.
`dependency_import_package`, `dependent_import_package`;
`dependency_packages` and `dependent_packages`, which contain both the
distribution and import packages for the dependency and the dependent,
respectively; and `next_supported_version`, and `version_used`, which
refer to supported and currently-used versions of the dependency.

"""
if not dependent_package or not dependency_name or not next_supported_version:
if (
not dependent_import_package
or not dependency_import_package
or not next_supported_version
):
return
version_used = get_dependency_version(dependency_name)
version_used = get_dependency_version(dependency_import_package)
if not version_used:
return
if version_used < parse_version(next_supported_version):
(
dependency_packages,
dependency_distribution_package,
) = _get_distribution_and_import_packages(dependency_import_package)
(
dependent_packages,
dependent_distribution_package,
) = _get_distribution_and_import_packages(dependent_import_package)
Copy link
Contributor

@daniel-sanche daniel-sanche Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these variables be made less similar? (dependent_x vs dependency_x)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're the correct terms, but they are very similar. I changed the less common term (IMO), dependent, to consumer.

message_template = message_template or _flatten_message(
"""DEPRECATION: Package {dependent_package} depends on
{dependency_name}, currently installed at version
"""DEPRECATION: Package {dependent_packages} depends on
{dependency_packages}, currently installed at version
{version_used.__str__}. Future updates to
{dependent_package} will require {dependency_name} at
{dependent_packages} will require {dependency_packages} at
version {next_supported_version} or higher. Please ensure
that either (a) your Python environment doesn't pin the
version of {dependency_name}, so that updates to
{dependent_package} can require the higher version, or (b)
you manually update your Python environment to use at
version of {dependency_packages}, so that updates to
{dependent_packages} can require the higher version, or
(b) you manually update your Python environment to use at
least version {next_supported_version} of
{dependency_name}."""
{dependency_packages}."""
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make this into a constant? Maybe DEFAULT_PACKAGE_DEPRECATION_TEMPLATE?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer not to because the message template is defined and directly used in one place, and it's closely coupled to the variables provided by the function. I think black-boxing it inside the function, and defining it where it's used, is clearer and more compact than defining a separate constant inside or outside the function.

logging.warning(
message_template.format(
dependent_package=dependent_package,
dependency_name=dependency_name,
dependent_import_package=dependent_import_package,
dependency_import_package=dependency_import_package,
next_supported_version=next_supported_version,
version_used=version_used,
)
)


def check_dependency_versions(dependent_package: str):
def check_dependency_versions(dependent_import_package: str):
"""Bundle checks for all package dependencies.

This function can be called by all dependents of google.api_core,
to emit needed deprecation warnings for any of their
dependencies. The dependencies to check should be updated here.

Args:
dependent_package: The distribution name of the calling package, whose
dependent_import_package: The distribution name of the calling package, whose
dependencies we're checking.

"""
warn_deprecation_for_versions_less_than(
dependent_package, "protobuf (google.protobuf)", "4.25.8"
dependent_import_package, "google.protobuf", "4.25.8"
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the idea that we will go back and manually add dependencies here in the future?

If so, this feels a little hidden. Can we read this from a config file, or declare it as a constant at the top of the file or something?

something like:

_DependencyConstraint = namedtuple("_DependencyConstraint", ["package_name", "min_version", "recommended_version"])

DEPENDENCIES = [
    _DependencyConstraint("google.protobuf", min_version="4.25.8", recommended_version="6.x")
]

def check_dependency_versions(dependent_import_package: str):
    for package_info in DEPENDENCIES:
        warn_deprecation_for_versions_less_than(
            dependent_import_package, 
            package_info.package_name, 
            package_info.min_version,
            recommended_version=package_info.recommended_version,
        )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what we have is simple and clear, though it does feel hidden just by the fact it's at the bottom of the file.

OK, I implemented the table at the top of the file.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It kind of feels like dependencies should be given as an argument too? Can was assume all sdks will want to check against the same ones?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, as variadic parameters, with the local list as a default.

79 changes: 56 additions & 23 deletions google/api_core/_python_version_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,44 @@ def _flatten_message(text: str) -> str:
return textwrap.dedent(text).strip().replace("\n", " ")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary? You could use parentheses to build the multi-line strings, without having to post-process it at runtime:

(
  f"You are using a non-supported Python version ({py_version_str}). "
  f"Google will not post any further updates to {package_label} "
  "supporting this Python version. Please upgrade to the latest Python "
  f"version, or at least Python {min_python(today)}, and then update "
  f"{package_label}."
)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contrariwise, is the optimization you suggest really necessary?

The advantage of the way I have it is that it makes changing the message simpler for developers: just write and re-wrap the text. The cost is that, yes, it does get called at run time, but only upon initialization and only once per message that is printed. So yes, it could add up, but it doesn't seem like that would be substantial.

If you feel strongly about it I'll change it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I raised this more for simplicity rather than performance concerns. Python has built-in functionality for building long strings, so adding a custom helper for this seemed unnecessary to me. But this is more of a nit; I don't think this helper is too complex, and I don't feel too strongly about it

If we keep it, can we add some unit tests or something around it though?



# TODO: Remove once we no longer support Python3.7
if sys.version_info < (3, 8):

def _get_pypi_package_name(module_name):
"""Determine the PyPI package name for a given module name."""
return None

else:
from importlib import metadata

def _get_pypi_package_name(module_name):
"""Determine the PyPI package name for a given module name."""
try:
# Get the mapping of modules to distributions
module_to_distributions = metadata.packages_distributions()

# Check if the module is found in the mapping
if module_name in module_to_distributions:
# The value is a list of distribution names, take the first one
return module_to_distributions[module_name][0]
else:
return None # Module not found in the mapping
except Exception as e:
print(f"An error occurred: {e}")
return None


def _get_distribution_and_import_packages(import_package: str) -> Optional[str]:
"""Return a pretty string with distribution & import package names."""
distribution_package = _get_pypi_package_name(import_package)
dependency_distribution_and_import_packages = (
f"package {distribution_package} ({import_package})"
if distribution_package
else import_package
)
return dependency_distribution_and_import_packages, distribution_package


def check_python_version(
package: Optional[str] = "this package", today: Optional[datetime.date] = None
) -> PythonVersionStatus:
Expand All @@ -111,6 +149,7 @@ def check_python_version(
The support status of the current Python version.
"""
today = today or datetime.date.today()
package_label, _ = _get_distribution_and_import_packages(package)

python_version = sys.version_info
version_tuple = (python_version.major, python_version.minor)
Expand All @@ -135,9 +174,7 @@ def check_python_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
)
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."""
Expand All @@ -148,42 +185,38 @@ def min_python(date: datetime.date) -> str:

if gapic_end < 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}. We suggest
you upgrade to the latest Python version, or at least Python
{min_python(today)}, and then update {package}.
"""
f"""You are using a non-supported Python version
({py_version_str}). Google will not post any further
updates to {package_label}. We suggest you upgrade to the
latest Python version, or at least Python
{min_python(today)}, and then update {package_label}. """
)
logging.warning(message)
return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED

eol_date = version_info.python_eol + EOL_GRACE_PERIOD
if eol_date <= today <= gapic_end:
message = _flatten_message(
f"""
You are using a Python version ({py_version_str}) past its end
of life. Google will update {package} with critical
bug fixes on a best-effort basis, but not with any other fixes or
features. We suggest you upgrade to the latest Python version,
or at least Python {min_python(today)}, and then update {package}.
"""
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. We suggest you upgrade
to the latest Python version, or at least Python
{min_python(today)}, and then update {package_label}."""
)
logging.warning(message)
return PythonVersionStatus.PYTHON_VERSION_EOL

if gapic_deprecation <= today <= gapic_end:
message = _flatten_message(
f"""
You are using a Python version ({py_version_str}),
which Google will stop supporting in {package} when it
reaches its end of life ({version_info.python_eol}). We
f"""You are using a Python version ({py_version_str}),
which Google will stop supporting in {package_label} when
it reaches its end of life ({version_info.python_eol}). We
suggest you upgrade to the latest Python version, or at
least Python {min_python(version_info.python_eol)}, and
then update {package}.
"""
then update {package_label}."""
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think these message (or at least templates) would be better as constants, instead of embedded in the function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, for the same reason as the other case. They are defined and used directly only once, and the substitution strings and the text are closely related to the logic of the function. I think it reads more clearly to have them defined inline, as they are now.

logging.warning(message)
return PythonVersionStatus.PYTHON_VERSION_DEPRECATED

return PythonVersionStatus.PYTHON_VERSION_SUPPORTED
return PythonVersionStatus.PYTHON_VERSION_SUPPORTED
30 changes: 15 additions & 15 deletions tests/unit/test_python_package_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ def test_get_dependency_version_py37(mock_get_distribution):
mock_get_distribution.assert_called_once_with("another-package")

# Test package not found
mock_get_distribution.side_effect = Exception # pkg_resources has its own exception types
mock_get_distribution.side_effect = (
Exception # pkg_resources has its own exception types
)
assert get_dependency_version("not-a-package") is None


Expand All @@ -58,34 +60,29 @@ def test_warn_deprecation_for_versions_less_than(mock_log_warning, mock_get_vers
"""Test the deprecation warning logic."""
# Case 1: Installed version is less than required, should warn.
mock_get_version.return_value = parse_version("1.0.0")
warn_deprecation_for_versions_less_than(
"my-package", "dep-package", "2.0.0"
)
warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0")
mock_log_warning.assert_called_once()
assert "DEPRECATION: Package my-package depends on dep-package" in mock_log_warning.call_args[0][0]
assert (
"DEPRECATION: Package my-package depends on dep-package"
in mock_log_warning.call_args[0][0]
)

# Case 2: Installed version is equal to required, should not warn.
mock_log_warning.reset_mock()
mock_get_version.return_value = parse_version("2.0.0")
warn_deprecation_for_versions_less_than(
"my-package", "dep-package", "2.0.0"
)
warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0")
mock_log_warning.assert_not_called()

# Case 3: Installed version is greater than required, should not warn.
mock_log_warning.reset_mock()
mock_get_version.return_value = parse_version("3.0.0")
warn_deprecation_for_versions_less_than(
"my-package", "dep-package", "2.0.0"
)
warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0")
mock_log_warning.assert_not_called()

# Case 4: Dependency not found, should not warn.
mock_log_warning.reset_mock()
mock_get_version.return_value = None
warn_deprecation_for_versions_less_than(
"my-package", "dep-package", "2.0.0"
)
warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0")
mock_log_warning.assert_not_called()

# Case 5: Custom message template.
Expand All @@ -96,4 +93,7 @@ def test_warn_deprecation_for_versions_less_than(mock_log_warning, mock_get_vers
"my-package", "dep-package", "2.0.0", message_template=template
)
mock_log_warning.assert_called_once()
assert "Custom warning for dep-package used by my-package." in mock_log_warning.call_args[0][0]
assert (
"Custom warning for dep-package used by my-package."
in mock_log_warning.call_args[0][0]
)