From 9603a85f214e6365659bd1d607dc7be8443c8b80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:05:14 -0500 Subject: [PATCH 1/2] Bump the actions group with 2 updates (#2550) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Steven Silvester --- .github/workflows/create-release-branch.yml | 6 +++--- .github/workflows/release-python.yml | 14 ++++++-------- .github/workflows/zizmor.yml | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml index 72345d4a4..95a5e65c8 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -33,11 +33,11 @@ jobs: outputs: version: ${{ steps.pre-publish.outputs.version }} steps: - - uses: mongodb-labs/drivers-github-tools/secure-checkout@v2 + - uses: mongodb-labs/drivers-github-tools/secure-checkout@v3 with: app_id: ${{ vars.APP_ID }} private_key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: mongodb-labs/drivers-github-tools/setup@v2 + - uses: mongodb-labs/drivers-github-tools/setup@v3 with: aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} aws_region_name: ${{ vars.AWS_REGION_NAME }} @@ -45,7 +45,7 @@ jobs: artifactory_username: ${{ vars.ARTIFACTORY_USERNAME }} - name: Get hatch run: pip install hatch - - uses: mongodb-labs/drivers-github-tools/create-branch@v2 + - uses: mongodb-labs/drivers-github-tools/create-branch@v3 id: create-branch with: branch_name: ${{ inputs.branch_name }} diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index a30afbccd..6abca9e52 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -38,17 +38,16 @@ jobs: outputs: version: ${{ steps.pre-publish.outputs.version }} steps: - - uses: mongodb-labs/drivers-github-tools/secure-checkout@v2 + - uses: mongodb-labs/drivers-github-tools/secure-checkout@v3 with: app_id: ${{ vars.APP_ID }} private_key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: mongodb-labs/drivers-github-tools/setup@v2 + - uses: mongodb-labs/drivers-github-tools/setup@v3 with: aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} aws_region_name: ${{ vars.AWS_REGION_NAME }} aws_secret_id: ${{ secrets.AWS_SECRET_ID }} - artifactory_username: ${{ vars.ARTIFACTORY_USERNAME }} - - uses: mongodb-labs/drivers-github-tools/python/pre-publish@v2 + - uses: mongodb-labs/drivers-github-tools/python/pre-publish@v3 id: pre-publish with: dry_run: ${{ env.DRY_RUN }} @@ -100,17 +99,16 @@ jobs: attestations: write security-events: write steps: - - uses: mongodb-labs/drivers-github-tools/secure-checkout@v2 + - uses: mongodb-labs/drivers-github-tools/secure-checkout@v3 with: app_id: ${{ vars.APP_ID }} private_key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: mongodb-labs/drivers-github-tools/setup@v2 + - uses: mongodb-labs/drivers-github-tools/setup@v3 with: aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} aws_region_name: ${{ vars.AWS_REGION_NAME }} aws_secret_id: ${{ secrets.AWS_SECRET_ID }} - artifactory_username: ${{ vars.ARTIFACTORY_USERNAME }} - - uses: mongodb-labs/drivers-github-tools/python/post-publish@v2 + - uses: mongodb-labs/drivers-github-tools/python/post-publish@v3 with: following_version: ${{ env.FOLLOWING_VERSION }} product_name: ${{ env.PRODUCT_NAME }} diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 31d8c1eef..a3eb5d550 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -18,4 +18,4 @@ jobs: with: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@873539476a7f9b0da7504d0d9e9a6a5275094d98 + uses: zizmorcore/zizmor-action@0696496a48b64e0568faa46ddaf5f6fe48b83b04 From 6fe85436ae62de6e352b87c2526c796e05471559 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 22 Sep 2025 17:15:02 -0500 Subject: [PATCH 2/2] PYTHON-3414 Improve error message when using incompatible dependencies (#2549) --- pymongo/asynchronous/encryption.py | 9 ++- pymongo/asynchronous/srv_resolver.py | 12 +++- pymongo/common.py | 89 ++++++++++++++++++++++++++++ pymongo/encryption_options.py | 17 +++++- pymongo/synchronous/encryption.py | 9 ++- pymongo/synchronous/srv_resolver.py | 12 +++- test/version.py | 64 +------------------- 7 files changed, 144 insertions(+), 68 deletions(-) diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index d32a5b320..4dfd36aa4 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -66,7 +66,12 @@ from pymongo.asynchronous.database import AsyncDatabase from pymongo.asynchronous.mongo_client import AsyncMongoClient from pymongo.common import CONNECT_TIMEOUT from pymongo.daemon import _spawn_daemon -from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts, TextOpts +from pymongo.encryption_options import ( + AutoEncryptionOpts, + RangeOpts, + TextOpts, + check_min_pymongocrypt, +) from pymongo.errors import ( ConfigurationError, EncryptedCollectionError, @@ -675,6 +680,8 @@ class AsyncClientEncryption(Generic[_DocumentType]): "python -m pip install --upgrade 'pymongo[encryption]'" ) + check_min_pymongocrypt() + if not isinstance(codec_options, CodecOptions): raise TypeError( f"codec_options must be an instance of bson.codec_options.CodecOptions, not {type(codec_options)}" diff --git a/pymongo/asynchronous/srv_resolver.py b/pymongo/asynchronous/srv_resolver.py index 8d0d40c27..006abbb61 100644 --- a/pymongo/asynchronous/srv_resolver.py +++ b/pymongo/asynchronous/srv_resolver.py @@ -19,7 +19,7 @@ import ipaddress import random from typing import TYPE_CHECKING, Any, Optional, Union -from pymongo.common import CONNECT_TIMEOUT +from pymongo.common import CONNECT_TIMEOUT, check_for_min_version from pymongo.errors import ConfigurationError if TYPE_CHECKING: @@ -32,6 +32,14 @@ def _have_dnspython() -> bool: try: import dns # noqa: F401 + dns_version, required_version, is_valid = check_for_min_version("dnspython") + if not is_valid: + raise RuntimeError( + f"pymongo requires dnspython>={required_version}, " + f"found version {dns_version}. " + "Install a compatible version with pip" + ) + return True except ImportError: return False @@ -80,6 +88,8 @@ class _SrvResolver: srv_service_name: str, srv_max_hosts: int = 0, ): + # Ensure the version of dnspython is compatible. + _have_dnspython() self.__fqdn = fqdn self.__srv = srv_service_name self.__connect_timeout = connect_timeout or CONNECT_TIMEOUT diff --git a/pymongo/common.py b/pymongo/common.py index 5210e7218..e23adac42 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -20,6 +20,7 @@ import datetime import warnings from collections import OrderedDict, abc from difflib import get_close_matches +from importlib.metadata import requires, version from typing import ( TYPE_CHECKING, Any, @@ -1092,3 +1093,91 @@ def has_c() -> bool: return True except ImportError: return False + + +class Version(tuple[int, ...]): + """A class that can be used to compare version strings.""" + + def __new__(cls, *version: int) -> Version: + padded_version = cls._padded(version, 4) + return super().__new__(cls, tuple(padded_version)) + + @classmethod + def _padded(cls, iter: Any, length: int, padding: int = 0) -> list[int]: + as_list = list(iter) + if len(as_list) < length: + for _ in range(length - len(as_list)): + as_list.append(padding) + return as_list + + @classmethod + def from_string(cls, version_string: str) -> Version: + mod = 0 + bump_patch_level = False + if version_string.endswith("+"): + version_string = version_string[0:-1] + mod = 1 + elif version_string.endswith("-pre-"): + version_string = version_string[0:-5] + mod = -1 + elif version_string.endswith("-"): + version_string = version_string[0:-1] + mod = -1 + # Deal with .devX substrings + if ".dev" in version_string: + version_string = version_string[0 : version_string.find(".dev")] + mod = -1 + # Deal with '-rcX' substrings + if "-rc" in version_string: + version_string = version_string[0 : version_string.find("-rc")] + mod = -1 + # Deal with git describe generated substrings + elif "-" in version_string: + version_string = version_string[0 : version_string.find("-")] + mod = -1 + bump_patch_level = True + + version = [int(part) for part in version_string.split(".")] + version = cls._padded(version, 3) + # Make from_string and from_version_array agree. For example: + # MongoDB Enterprise > db.runCommand('buildInfo').versionArray + # [ 3, 2, 1, -100 ] + # MongoDB Enterprise > db.runCommand('buildInfo').version + # 3.2.0-97-g1ef94fe + if bump_patch_level: + version[-1] += 1 + version.append(mod) + + return Version(*version) + + @classmethod + def from_version_array(cls, version_array: Any) -> Version: + version = list(version_array) + if version[-1] < 0: + version[-1] = -1 + version = cls._padded(version, 3) + return Version(*version) + + def at_least(self, *other_version: Any) -> bool: + return self >= Version(*other_version) + + def __str__(self) -> str: + return ".".join(map(str, self)) + + +def check_for_min_version(package_name: str) -> tuple[str, str, bool]: + """Test whether an installed package is of the desired version.""" + package_version_str = version(package_name) + package_version = Version.from_string(package_version_str) + # Dependency is expected to be in one of the forms: + # "pymongocrypt<2.0.0,>=1.13.0; extra == 'encryption'" + # 'dnspython<3.0.0,>=1.16.0' + # + requirements = requires("pymongo") + assert requirements is not None + requirement = [i for i in requirements if i.startswith(package_name)][0] # noqa: RUF015 + if ";" in requirement: + requirement = requirement.split(";")[0] + required_version = requirement[requirement.find(">=") + 2 :] + is_valid = package_version >= Version.from_string(required_version) + return package_version_str, required_version, is_valid diff --git a/pymongo/encryption_options.py b/pymongo/encryption_options.py index da34a3be5..b2037617b 100644 --- a/pymongo/encryption_options.py +++ b/pymongo/encryption_options.py @@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, Any, Mapping, Optional, TypedDict from pymongo.uri_parser_shared import _parse_kms_tls_options try: - import pymongocrypt # type:ignore[import-untyped] # noqa: F401 + import pymongocrypt # type:ignore[import-untyped] # noqa: F401 # Check for pymongocrypt>=1.10. from pymongocrypt import synchronous as _ # noqa: F401 @@ -32,7 +32,7 @@ try: except ImportError: _HAVE_PYMONGOCRYPT = False from bson import int64 -from pymongo.common import validate_is_mapping +from pymongo.common import check_for_min_version, validate_is_mapping from pymongo.errors import ConfigurationError if TYPE_CHECKING: @@ -40,6 +40,18 @@ if TYPE_CHECKING: from pymongo.typings import _AgnosticMongoClient +def check_min_pymongocrypt() -> None: + """Raise an appropriate error if the min pymongocrypt is not installed.""" + pymongocrypt_version, required_version, is_valid = check_for_min_version("pymongocrypt") + if not is_valid: + raise ConfigurationError( + f"client side encryption requires pymongocrypt>={required_version}, " + f"found version {pymongocrypt_version}. " + "Install a compatible version with: " + "python -m pip install 'pymongo[encryption]'" + ) + + class AutoEncryptionOpts: """Options to configure automatic client-side field level encryption.""" @@ -215,6 +227,7 @@ class AutoEncryptionOpts: "install a compatible version with: " "python -m pip install 'pymongo[encryption]'" ) + check_min_pymongocrypt() if encrypted_fields_map: validate_is_mapping("encrypted_fields_map", encrypted_fields_map) self._encrypted_fields_map = encrypted_fields_map diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index f9d51a9ea..2d666b976 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -61,7 +61,12 @@ from bson.raw_bson import DEFAULT_RAW_BSON_OPTIONS, RawBSONDocument, _inflate_bs from pymongo import _csot from pymongo.common import CONNECT_TIMEOUT from pymongo.daemon import _spawn_daemon -from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts, TextOpts +from pymongo.encryption_options import ( + AutoEncryptionOpts, + RangeOpts, + TextOpts, + check_min_pymongocrypt, +) from pymongo.errors import ( ConfigurationError, EncryptedCollectionError, @@ -672,6 +677,8 @@ class ClientEncryption(Generic[_DocumentType]): "python -m pip install --upgrade 'pymongo[encryption]'" ) + check_min_pymongocrypt() + if not isinstance(codec_options, CodecOptions): raise TypeError( f"codec_options must be an instance of bson.codec_options.CodecOptions, not {type(codec_options)}" diff --git a/pymongo/synchronous/srv_resolver.py b/pymongo/synchronous/srv_resolver.py index f6e99a3ea..8e492061a 100644 --- a/pymongo/synchronous/srv_resolver.py +++ b/pymongo/synchronous/srv_resolver.py @@ -19,7 +19,7 @@ import ipaddress import random from typing import TYPE_CHECKING, Any, Optional, Union -from pymongo.common import CONNECT_TIMEOUT +from pymongo.common import CONNECT_TIMEOUT, check_for_min_version from pymongo.errors import ConfigurationError if TYPE_CHECKING: @@ -32,6 +32,14 @@ def _have_dnspython() -> bool: try: import dns # noqa: F401 + dns_version, required_version, is_valid = check_for_min_version("dnspython") + if not is_valid: + raise RuntimeError( + f"pymongo requires dnspython>={required_version}, " + f"found version {dns_version}. " + "Install a compatible version with pip" + ) + return True except ImportError: return False @@ -80,6 +88,8 @@ class _SrvResolver: srv_service_name: str, srv_max_hosts: int = 0, ): + # Ensure the version of dnspython is compatible. + _have_dnspython() self.__fqdn = fqdn self.__srv = srv_service_name self.__connect_timeout = connect_timeout or CONNECT_TIMEOUT diff --git a/test/version.py b/test/version.py index 42d53cfcf..ae6ecb331 100644 --- a/test/version.py +++ b/test/version.py @@ -15,64 +15,10 @@ """Some tools for running tests based on MongoDB server version.""" from __future__ import annotations +from pymongo.common import Version as BaseVersion -class Version(tuple): - def __new__(cls, *version): - padded_version = cls._padded(version, 4) - return super().__new__(cls, tuple(padded_version)) - - @classmethod - def _padded(cls, iter, length, padding=0): - l = list(iter) - if len(l) < length: - for _ in range(length - len(l)): - l.append(padding) - return l - - @classmethod - def from_string(cls, version_string): - mod = 0 - bump_patch_level = False - if version_string.endswith("+"): - version_string = version_string[0:-1] - mod = 1 - elif version_string.endswith("-pre-"): - version_string = version_string[0:-5] - mod = -1 - elif version_string.endswith("-"): - version_string = version_string[0:-1] - mod = -1 - # Deal with '-rcX' substrings - if "-rc" in version_string: - version_string = version_string[0 : version_string.find("-rc")] - mod = -1 - # Deal with git describe generated substrings - elif "-" in version_string: - version_string = version_string[0 : version_string.find("-")] - mod = -1 - bump_patch_level = True - - version = [int(part) for part in version_string.split(".")] - version = cls._padded(version, 3) - # Make from_string and from_version_array agree. For example: - # MongoDB Enterprise > db.runCommand('buildInfo').versionArray - # [ 3, 2, 1, -100 ] - # MongoDB Enterprise > db.runCommand('buildInfo').version - # 3.2.0-97-g1ef94fe - if bump_patch_level: - version[-1] += 1 - version.append(mod) - - return Version(*version) - - @classmethod - def from_version_array(cls, version_array): - version = list(version_array) - if version[-1] < 0: - version[-1] = -1 - version = cls._padded(version, 3) - return Version(*version) +class Version(BaseVersion): @classmethod def from_client(cls, client): info = client.server_info() @@ -86,9 +32,3 @@ class Version(tuple): if "versionArray" in info: return cls.from_version_array(info["versionArray"]) return cls.from_string(info["version"]) - - def at_least(self, *other_version): - return self >= Version(*other_version) - - def __str__(self): - return ".".join(map(str, self))