From 1da3a3e554a2a5ee3d5d696914f71a8a301e0b8e Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 12 Aug 2026 12:49:06 -0400 Subject: [PATCH 1/8] PYTHON-5909 Add GA support for Queryable Encryption string queries --- doc/changelog.rst | 16 + pymongo/asynchronous/encryption.py | 90 ++++- pymongo/encryption_options.py | 48 ++- pymongo/synchronous/encryption.py | 90 ++++- test/asynchronous/test_encryption.py | 525 +++++++++++++++++++-------- test/test_encryption.py | 521 ++++++++++++++++++-------- 6 files changed, 952 insertions(+), 338 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index db6e0eba20..0434186ce1 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -31,6 +31,22 @@ PyMongo 4.18 brings a number of changes including: - Fixed a bug on Windows, and on macOS when using PyOpenSSL, where ``SSL_CERT_FILE``/``SSL_CERT_DIR`` were merged with, rather than replacing, the OS/certifi certificate store. +- Added general availability support for Queryable Encryption prefix, suffix, + and substring queries against MongoDB 9.0+, which requires libmongocrypt + 1.20.0 or later: + + - Added :attr:`~pymongo.encryption.Algorithm.STRING` and + :class:`~pymongo.encryption_options.StringOpts`, replacing + ``Algorithm.TEXTPREVIEW`` and ``TextOpts``, which are now deprecated. + - Added :attr:`~pymongo.encryption.QueryType.PREFIX`, + :attr:`~pymongo.encryption.QueryType.SUFFIX`, and + :attr:`~pymongo.encryption.QueryType.SUBSTRING`. The corresponding + ``PREFIXPREVIEW``, ``SUFFIXPREVIEW``, and ``SUBSTRINGPREVIEW`` query types + remain for experimental use with MongoDB versions before 9.0. + - Added the ``string_opts`` parameter to + :meth:`~pymongo.encryption.ClientEncryption.encrypt` and + :meth:`~pymongo.asynchronous.encryption.AsyncClientEncryption.encrypt`, + deprecating ``text_opts``. Changes in Version 4.17.0 (2026/04/20) -------------------------------------- diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index 524ae45c11..e413077470 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -22,6 +22,7 @@ import socket import time as time # noqa: PLC0414 # needed in sync version import uuid +import warnings import weakref from collections.abc import AsyncGenerator, Iterator, Mapping, MutableMapping, Sequence from copy import deepcopy @@ -65,7 +66,7 @@ from pymongo.encryption_options import ( AutoEncryptionOpts, RangeOpts, - TextOpts, + StringOpts, check_min_pymongocrypt, ) from pymongo.errors import ( @@ -529,8 +530,15 @@ class Algorithm(str, enum.Enum): .. versionadded:: 4.4 """ + STRING = "String" + """String. + + .. versionadded:: 4.18 + """ TEXTPREVIEW = "TextPreview" - """**BETA** - TextPreview. + """**DEPRECATED** - TextPreview. + + .. note:: Support for TextPreview is deprecated. Use :attr:`Algorithm.STRING` instead. .. versionadded:: 4.15 """ @@ -559,25 +567,77 @@ class QueryType(str, enum.Enum): .. versionadded:: 4.4 """ + PREFIX = "prefix" + """Used to encrypt a value for a prefix query. + + Used for the ``$encStrStartsWith`` operator. Requires MongoDB 9.0+. + + .. versionadded:: 4.18 + """ + + SUFFIX = "suffix" + """Used to encrypt a value for a suffix query. + + Used for the ``$encStrEndsWith`` operator. Requires MongoDB 9.0+. + + .. versionadded:: 4.18 + """ + + SUBSTRING = "substring" + """Used to encrypt a value for a substring query. + + Used for the ``$encStrContains`` operator. Requires MongoDB 9.0+. + + .. versionadded:: 4.18 + """ + PREFIXPREVIEW = "prefixPreview" """**BETA** - Used to encrypt a value for a prefixPreview query. + .. note:: The preview query types are for experimental workloads only and + are only supported by MongoDB versions before 9.0. Use + :attr:`QueryType.PREFIX` instead. + .. versionadded:: 4.15 """ SUFFIXPREVIEW = "suffixPreview" """**BETA** - Used to encrypt a value for a suffixPreview query. + .. note:: The preview query types are for experimental workloads only and + are only supported by MongoDB versions before 9.0. Use + :attr:`QueryType.SUFFIX` instead. + .. versionadded:: 4.15 """ SUBSTRINGPREVIEW = "substringPreview" """**BETA** - Used to encrypt a value for a substringPreview query. + .. note:: The preview query types are for experimental workloads only and + are only supported by MongoDB versions before 9.0. Use + :attr:`QueryType.SUBSTRING` instead. + .. versionadded:: 4.15 """ +def _resolve_string_opts( + string_opts: Optional[StringOpts], text_opts: Optional[StringOpts] +) -> Optional[StringOpts]: + """Resolve the deprecated text_opts alias for string_opts.""" + if text_opts is None: + return string_opts + if string_opts is not None: + raise ConfigurationError("Cannot set both string_opts and text_opts") + warnings.warn( + "The text_opts parameter is deprecated. Use string_opts instead.", + DeprecationWarning, + stacklevel=3, + ) + return text_opts + + def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions: # For compat with pymongocrypt <1.13, avoid setting the default key_expiration_ms. if kwargs.get("key_expiration_ms") is None: @@ -917,7 +977,7 @@ async def _encrypt_helper( contention_factor: Optional[int] = None, range_opts: Optional[RangeOpts] = None, is_expression: bool = False, - text_opts: Optional[TextOpts] = None, + string_opts: Optional[StringOpts] = None, ) -> Any: self._check_closed() if isinstance(key_id, uuid.UUID): @@ -937,10 +997,10 @@ async def _encrypt_helper( range_opts.document, codec_options=self._codec_options, ) - text_opts_bytes = None - if text_opts: - text_opts_bytes = encode( - text_opts.document, + string_opts_bytes = None + if string_opts: + string_opts_bytes = encode( + string_opts.document, codec_options=self._codec_options, ) with _wrap_encryption_errors(): @@ -953,8 +1013,9 @@ async def _encrypt_helper( contention_factor=contention_factor, range_opts=range_opts_bytes, is_expression=is_expression, + # pymongocrypt still names this parameter text_opts. # For compatibility with pymongocrypt < 1.16: - **{"text_opts": text_opts_bytes} if text_opts_bytes else {}, + **{"text_opts": string_opts_bytes} if string_opts_bytes else {}, ) return decode(encrypted_doc)["v"] @@ -967,7 +1028,8 @@ async def encrypt( query_type: Optional[str] = None, contention_factor: Optional[int] = None, range_opts: Optional[RangeOpts] = None, - text_opts: Optional[TextOpts] = None, + string_opts: Optional[StringOpts] = None, + text_opts: Optional[StringOpts] = None, ) -> Binary: """Encrypt a BSON value with a given key and algorithm. @@ -988,11 +1050,15 @@ async def encrypt( used. :param range_opts: Index options for `range` queries. See :class:`RangeOpts` for some valid options. - :param text_opts: Index options for `textPreview` queries. See - :class:`TextOpts` for some valid options. + :param string_opts: Index options for `prefix`, `suffix`, and + `substring` queries. See :class:`StringOpts` for some valid options. + :param text_opts: **DEPRECATED** - Alias for `string_opts`. :return: The encrypted value, a :class:`~bson.binary.Binary` with subtype 6. + .. versionchanged:: 4.18 + Added the `string_opts` parameter and deprecated `text_opts`. + .. versionchanged:: 4.9 Added the `text_opts` parameter. @@ -1016,7 +1082,7 @@ async def encrypt( contention_factor=contention_factor, range_opts=range_opts, is_expression=False, - text_opts=text_opts, + string_opts=_resolve_string_opts(string_opts, text_opts), ), ) diff --git a/pymongo/encryption_options.py b/pymongo/encryption_options.py index f2fcd47c65..065e7f1590 100644 --- a/pymongo/encryption_options.py +++ b/pymongo/encryption_options.py @@ -19,6 +19,7 @@ from __future__ import annotations +import warnings from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Optional, TypedDict @@ -312,10 +313,8 @@ def document(self) -> dict[str, Any]: return doc -class TextOpts: - """**BETA** Options to configure encrypted queries using the text algorithm. - - TextOpts is currently unstable API and subject to backwards breaking changes.""" +class StringOpts: + """Options to configure encrypted queries using the string algorithm.""" def __init__( self, @@ -325,15 +324,16 @@ def __init__( case_sensitive: Optional[bool] = None, diacritic_sensitive: Optional[bool] = None, ) -> None: - """Options to configure encrypted queries using the text algorithm. + """Options to configure encrypted queries using the string algorithm. :param substring: Further options to support substring queries. :param prefix: Further options to support prefix queries. :param suffix: Further options to support suffix queries. - :param case_sensitive: Whether text indexes for this field are case sensitive. - :param diacritic_sensitive: Whether text indexes for this field are diacritic sensitive. + :param case_sensitive: Whether string indexes for this field are case sensitive. + :param diacritic_sensitive: Whether string indexes for this field are diacritic sensitive. - .. versionadded:: 4.15 + .. versionadded:: 4.18 + ``StringOpts`` replaces ``TextOpts``, which is deprecated. """ self.substring = substring self.prefix = prefix @@ -357,9 +357,9 @@ def document(self) -> dict[str, Any]: class SubstringOpts(TypedDict): - """**BETA** Options for substring text queries. + """Options for substring string queries. - SubstringOpts is currently unstable API and subject to backwards breaking changes. + .. versionadded:: 4.15 """ # strMaxLength is the maximum allowed length to insert. Inserting longer strings will error. @@ -371,9 +371,9 @@ class SubstringOpts(TypedDict): class PrefixOpts(TypedDict): - """**BETA** Options for prefix text queries. + """Options for prefix string queries. - PrefixOpts is currently unstable API and subject to backwards breaking changes. + .. versionadded:: 4.15 """ # strMinQueryLength is the minimum allowed query length. Querying with a shorter string will error. @@ -383,12 +383,32 @@ class PrefixOpts(TypedDict): class SuffixOpts(TypedDict): - """**BETA** Options for suffix text queries. + """Options for suffix string queries. - SuffixOpts is currently unstable API and subject to backwards breaking changes. + .. versionadded:: 4.15 """ # strMinQueryLength is the minimum allowed query length. Querying with a shorter string will error. strMinQueryLength: int # strMaxQueryLength is the maximum allowed query length. Querying with a longer string will error. strMaxQueryLength: int + + +class TextOpts(StringOpts): + """**DEPRECATED** Options to configure encrypted queries using the text algorithm. + + .. note:: ``TextOpts`` is deprecated. Use :class:`StringOpts` instead. + + .. versionadded:: 4.15 + + .. versionchanged:: 4.18 + Deprecated in favor of :class:`StringOpts`. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + warnings.warn( + "TextOpts is deprecated. Use StringOpts instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index 014d162e2b..33669bd525 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -21,6 +21,7 @@ import socket import time as time # noqa: PLC0414 # needed in sync version import uuid +import warnings import weakref from collections.abc import Generator, Iterator, Mapping, MutableMapping, Sequence from copy import deepcopy @@ -60,7 +61,7 @@ from pymongo.encryption_options import ( AutoEncryptionOpts, RangeOpts, - TextOpts, + StringOpts, check_min_pymongocrypt, ) from pymongo.errors import ( @@ -526,8 +527,15 @@ class Algorithm(str, enum.Enum): .. versionadded:: 4.4 """ + STRING = "String" + """String. + + .. versionadded:: 4.18 + """ TEXTPREVIEW = "TextPreview" - """**BETA** - TextPreview. + """**DEPRECATED** - TextPreview. + + .. note:: Support for TextPreview is deprecated. Use :attr:`Algorithm.STRING` instead. .. versionadded:: 4.15 """ @@ -556,25 +564,77 @@ class QueryType(str, enum.Enum): .. versionadded:: 4.4 """ + PREFIX = "prefix" + """Used to encrypt a value for a prefix query. + + Used for the ``$encStrStartsWith`` operator. Requires MongoDB 9.0+. + + .. versionadded:: 4.18 + """ + + SUFFIX = "suffix" + """Used to encrypt a value for a suffix query. + + Used for the ``$encStrEndsWith`` operator. Requires MongoDB 9.0+. + + .. versionadded:: 4.18 + """ + + SUBSTRING = "substring" + """Used to encrypt a value for a substring query. + + Used for the ``$encStrContains`` operator. Requires MongoDB 9.0+. + + .. versionadded:: 4.18 + """ + PREFIXPREVIEW = "prefixPreview" """**BETA** - Used to encrypt a value for a prefixPreview query. + .. note:: The preview query types are for experimental workloads only and + are only supported by MongoDB versions before 9.0. Use + :attr:`QueryType.PREFIX` instead. + .. versionadded:: 4.15 """ SUFFIXPREVIEW = "suffixPreview" """**BETA** - Used to encrypt a value for a suffixPreview query. + .. note:: The preview query types are for experimental workloads only and + are only supported by MongoDB versions before 9.0. Use + :attr:`QueryType.SUFFIX` instead. + .. versionadded:: 4.15 """ SUBSTRINGPREVIEW = "substringPreview" """**BETA** - Used to encrypt a value for a substringPreview query. + .. note:: The preview query types are for experimental workloads only and + are only supported by MongoDB versions before 9.0. Use + :attr:`QueryType.SUBSTRING` instead. + .. versionadded:: 4.15 """ +def _resolve_string_opts( + string_opts: Optional[StringOpts], text_opts: Optional[StringOpts] +) -> Optional[StringOpts]: + """Resolve the deprecated text_opts alias for string_opts.""" + if text_opts is None: + return string_opts + if string_opts is not None: + raise ConfigurationError("Cannot set both string_opts and text_opts") + warnings.warn( + "The text_opts parameter is deprecated. Use string_opts instead.", + DeprecationWarning, + stacklevel=3, + ) + return text_opts + + def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions: # For compat with pymongocrypt <1.13, avoid setting the default key_expiration_ms. if kwargs.get("key_expiration_ms") is None: @@ -910,7 +970,7 @@ def _encrypt_helper( contention_factor: Optional[int] = None, range_opts: Optional[RangeOpts] = None, is_expression: bool = False, - text_opts: Optional[TextOpts] = None, + string_opts: Optional[StringOpts] = None, ) -> Any: self._check_closed() if isinstance(key_id, uuid.UUID): @@ -930,10 +990,10 @@ def _encrypt_helper( range_opts.document, codec_options=self._codec_options, ) - text_opts_bytes = None - if text_opts: - text_opts_bytes = encode( - text_opts.document, + string_opts_bytes = None + if string_opts: + string_opts_bytes = encode( + string_opts.document, codec_options=self._codec_options, ) with _wrap_encryption_errors(): @@ -946,8 +1006,9 @@ def _encrypt_helper( contention_factor=contention_factor, range_opts=range_opts_bytes, is_expression=is_expression, + # pymongocrypt still names this parameter text_opts. # For compatibility with pymongocrypt < 1.16: - **{"text_opts": text_opts_bytes} if text_opts_bytes else {}, + **{"text_opts": string_opts_bytes} if string_opts_bytes else {}, ) return decode(encrypted_doc)["v"] @@ -960,7 +1021,8 @@ def encrypt( query_type: Optional[str] = None, contention_factor: Optional[int] = None, range_opts: Optional[RangeOpts] = None, - text_opts: Optional[TextOpts] = None, + string_opts: Optional[StringOpts] = None, + text_opts: Optional[StringOpts] = None, ) -> Binary: """Encrypt a BSON value with a given key and algorithm. @@ -981,11 +1043,15 @@ def encrypt( used. :param range_opts: Index options for `range` queries. See :class:`RangeOpts` for some valid options. - :param text_opts: Index options for `textPreview` queries. See - :class:`TextOpts` for some valid options. + :param string_opts: Index options for `prefix`, `suffix`, and + `substring` queries. See :class:`StringOpts` for some valid options. + :param text_opts: **DEPRECATED** - Alias for `string_opts`. :return: The encrypted value, a :class:`~bson.binary.Binary` with subtype 6. + .. versionchanged:: 4.18 + Added the `string_opts` parameter and deprecated `text_opts`. + .. versionchanged:: 4.9 Added the `text_opts` parameter. @@ -1009,7 +1075,7 @@ def encrypt( contention_factor=contention_factor, range_opts=range_opts, is_expression=False, - text_opts=text_opts, + string_opts=_resolve_string_opts(string_opts, text_opts), ), ) diff --git a/test/asynchronous/test_encryption.py b/test/asynchronous/test_encryption.py index bbd324df32..27c85cb984 100644 --- a/test/asynchronous/test_encryption.py +++ b/test/asynchronous/test_encryption.py @@ -63,7 +63,13 @@ from pymongo.asynchronous.helpers import anext from pymongo.asynchronous.mongo_client import AsyncMongoClient from pymongo.cursor_shared import CursorType -from pymongo.encryption_options import _HAVE_PYMONGOCRYPT, AutoEncryptionOpts, RangeOpts, TextOpts +from pymongo.encryption_options import ( + _HAVE_PYMONGOCRYPT, + AutoEncryptionOpts, + RangeOpts, + StringOpts, + TextOpts, +) from pymongo.errors import ( AutoReconnect, BulkWriteError, @@ -104,6 +110,7 @@ camel_to_snake_args, is_greenthread_patched, ) +from test.version import Version _IS_SYNC = False @@ -229,6 +236,32 @@ async def test_kwargs(self): self.assertEqual(get_client_opts(client).auto_encryption_opts, opts) +class TestStringOptsDeprecation(AsyncPyMongoTestCase): + def test_text_opts_is_deprecated(self): + with self.assertWarns(DeprecationWarning): + opts = TextOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + self.assertIsInstance(opts, StringOpts) + self.assertEqual( + StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}).document, + opts.document, + ) + + def test_resolve_string_opts(self): + string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + self.assertIsNone(encryption._resolve_string_opts(None, None)) + self.assertIs(encryption._resolve_string_opts(string_opts, None), string_opts) + + def test_resolve_string_opts_text_opts_is_deprecated(self): + string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + with self.assertWarns(DeprecationWarning): + self.assertIs(encryption._resolve_string_opts(None, string_opts), string_opts) + + def test_resolve_string_opts_rejects_both(self): + string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + with self.assertRaises(ConfigurationError): + encryption._resolve_string_opts(string_opts, string_opts) + + class AsyncEncryptionIntegrationTest(AsyncIntegrationTest): """Base class for encryption integration tests.""" @@ -3313,13 +3346,21 @@ async def test_collection_name_collision(self): self.assertIsInstance(exc.exception.encrypted_fields["fields"][0]["keyId"], Binary) -# https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-text-explicit-encryption -@unittest.skip("PYTHON-5799 need to add support for the new query type") -class TestExplicitTextEncryptionProse(AsyncEncryptionIntegrationTest): +def _libmongocrypt_at_least(*version): + """Return True if the installed libmongocrypt is at least `version`.""" + from pymongocrypt import libmongocrypt_version + + return Version.from_string(libmongocrypt_version()) >= Version(*version) + + +# https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-string-explicit-encryption +class TestStringExplicitEncryptionProse(AsyncEncryptionIntegrationTest): + # The GA collections require server 9.0+, the preview collections require + # server pre-9.0. Test Setup encrypts with the "String" algorithm, which + # requires libmongocrypt 1.19.0+. @async_client_context.require_no_standalone @async_client_context.require_version_min(8, 2, -1) - @async_client_context.require_version_max(8, 99, 99) - @async_client_context.require_libmongocrypt_min(1, 15, 1) + @async_client_context.require_libmongocrypt_min(1, 19, 0) @async_client_context.require_pymongocrypt_min(1, 16, 0) async def asyncSetUp(self): await super().asyncSetUp() @@ -3339,210 +3380,255 @@ async def asyncSetUp(self): self.client, OPTS, ) - # Create a MongoClient named encryptedClient with these AutoEncryptionOpts. - opts = AutoEncryptionOpts( - self.kms_providers, - "keyvault.datakeys", - bypass_query_analysis=True, + # Create a MongoClient named explicitEncryptedClient with these AutoEncryptionOpts. + self.client_encrypted = await self.async_rs_or_single_client( + auto_encryption_opts=AutoEncryptionOpts( + self.kms_providers, + "keyvault.datakeys", + bypass_query_analysis=True, + ) + ) + # Create a MongoClient named autoEncryptedClient with these AutoEncryptionOpts. + self.client_auto_encrypted = await self.async_rs_or_single_client( + auto_encryption_opts=AutoEncryptionOpts( + self.kms_providers, + "keyvault.datakeys", + ) ) - self.client_encrypted = await self.async_rs_or_single_client(auto_encryption_opts=opts) - # Using QE CreateCollection() and Collection.Drop(), drop and create the following collections with majority write concern: - # db.prefix-suffix using the encryptedFields option set to the contents of encryptedFields-prefix-suffix.json. + # The GA query types ("prefix", "suffix", "substring") require server + # 9.0+, which in turn dropped the preview query types. + self.is_ga = async_client_context.version.at_least(9, 0, -1) + + # Using QE CreateCollection() and Collection.Drop(), drop and create the + # collections with majority write concern. db = self.client_encrypted.db - await db.drop_collection("prefix-suffix") - encrypted_fields = json_data("etc", "data", "encryptedFields-prefix-suffix.json") - await self.client_encryption.create_encrypted_collection( - db, "prefix-suffix", kms_provider="local", encrypted_fields=encrypted_fields - ) - # db.substring using the encryptedFields option set to the contents of encryptedFields-substring.json. - await db.drop_collection("substring") - encrypted_fields = json_data("etc", "data", "encryptedFields-substring.json") - await self.client_encryption.create_encrypted_collection( - db, "substring", kms_provider="local", encrypted_fields=encrypted_fields - ) + if self.is_ga: + collections = ["prefix-suffix", "prefix-suffix-ci-di", "substring", "substring-ci-di"] + else: + collections = ["prefix-suffix-preview", "substring-preview"] + for name in collections: + await db.drop_collection(name) + await self.client_encryption.create_encrypted_collection( + db, + name, + kms_provider="local", + encrypted_fields=json_data("etc", "data", f"encryptedFields-{name}.json"), + ) # Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts. - text_opts = TextOpts( - case_sensitive=True, - diacritic_sensitive=True, - prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), - suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), - ) encrypted_value = await self.client_encryption.encrypt( "foobarbaz", key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, + algorithm=Algorithm.STRING, contention_factor=0, - text_opts=text_opts, + string_opts=StringOpts( + case_sensitive=True, + diacritic_sensitive=True, + prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), + suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ), ) - # Use encryptedClient to insert the following document into db.prefix-suffix with majority write concern. - coll = self.client_encrypted.db["prefix-suffix"].with_options( - write_concern=WriteConcern(w="majority") + # Use explicitEncryptedClient to insert the following document into + # db.prefix-suffix (if created) and db.prefix-suffix-preview (if created) + # with majority write concern. + await self._insert( + "prefix-suffix" if self.is_ga else "prefix-suffix-preview", + {"_id": 0, "encryptedText": encrypted_value}, ) - await coll.insert_one({"_id": 0, "encryptedText": encrypted_value}) # Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts. - text_opts = TextOpts( - case_sensitive=True, - diacritic_sensitive=True, - substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2), - ) encrypted_value = await self.client_encryption.encrypt( "foobarbaz", key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, + algorithm=Algorithm.STRING, contention_factor=0, - text_opts=text_opts, + string_opts=StringOpts( + case_sensitive=True, + diacritic_sensitive=True, + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), + ), ) - # Use encryptedClient to insert the following document into db.substring with majority write concern. - coll = self.client_encrypted.db["substring"].with_options( - write_concern=WriteConcern(w="majority") + # Use explicitEncryptedClient to insert the following document into + # db.substring (if created) and db.substring-preview (if created) with + # majority write concern. + await self._insert( + "substring" if self.is_ga else "substring-preview", + {"_id": 0, "encryptedText": encrypted_value}, + ) + + async def _insert(self, collection, document, client=None): + """Insert a document with majority write concern.""" + client = client or self.client_encrypted + coll = client.db[collection].with_options(write_concern=WriteConcern(w="majority")) + await coll.insert_one(document) + + def _params(self, kind): + """Return the (query_type, collection) pair to run a case against. + + Each case runs against the GA query type on server 9.0+ and against the + preview query type on earlier servers, skipping when the installed + libmongocrypt is too old for the applicable variant. + """ + if kind == "substring": + base, ga_req, preview_req = "substring", (1, 20, 0), (1, 18, 1) + else: + base, ga_req, preview_req = "prefix-suffix", (1, 19, 0), (1, 19, 1) + if self.is_ga: + query_type, collection, required = kind, base, ga_req + else: + query_type, collection, required = f"{kind}Preview", f"{base}-preview", preview_req + if not _libmongocrypt_at_least(*required): + raise unittest.SkipTest( + f"queryType={query_type} requires libmongocrypt {'.'.join(map(str, required))}+" + ) + return query_type, collection + + def _require_ga(self, *libmongocrypt_version): + """Skip a case that only applies to the GA query types.""" + if not self.is_ga: + raise unittest.SkipTest("requires server 9.0+") + if not _libmongocrypt_at_least(*libmongocrypt_version): + raise unittest.SkipTest( + f"requires libmongocrypt {'.'.join(map(str, libmongocrypt_version))}+" + ) + + async def _encrypt(self, value, query_type=None, **string_opts): + return await self.client_encryption.encrypt( + value, + key_id=self.key1_id, + algorithm=Algorithm.STRING, + query_type=query_type, + contention_factor=0, + string_opts=StringOpts(**string_opts), ) - await coll.insert_one({"_id": 0, "encryptedText": encrypted_value}) + + async def _find(self, collection, filter): + value = await self.client_encrypted.db[collection].find_one(filter) + if value is not None: + value.pop("__safeContent__", None) + return value async def test_01_can_find_a_document_by_prefix(self): - # Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts. - text_opts = TextOpts( + query_type, collection = self._params("prefix") + # Use clientEncryption.encrypt() to encrypt the string "foo". + encrypted_value = await self._encrypt( + "foo", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), ) - encrypted_value = await self.client_encryption.encrypt( - "foo", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.PREFIXPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter. - value = await self.client_encrypted.db["prefix-suffix"].find_one( - {"$expr": {"$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value}}} + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + collection, + { + "$expr": { + "$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value} + } + }, ) # Assert the following document is returned. - expected = {"_id": 0, "encryptedText": "foobarbaz"} - value.pop("__safeContent__", None) - self.assertEqual(value, expected) + self.assertEqual(value, {"_id": 0, "encryptedText": "foobarbaz"}) async def test_02_can_find_a_document_by_suffix(self): - # Use clientEncryption.encrypt() to encrypt the string "baz" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("suffix") + # Use clientEncryption.encrypt() to encrypt the string "baz". + encrypted_value = await self._encrypt( + "baz", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), ) - encrypted_value = await self.client_encryption.encrypt( - "baz", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.SUFFIXPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter: - value = await self.client_encrypted.db["prefix-suffix"].find_one( - {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}} + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + collection, + {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}, ) # Assert the following document is returned. - expected = {"_id": 0, "encryptedText": "foobarbaz"} - value.pop("__safeContent__", None) - self.assertEqual(value, expected) + self.assertEqual(value, {"_id": 0, "encryptedText": "foobarbaz"}) async def test_03_no_document_found_by_prefix(self): - # Use clientEncryption.encrypt() to encrypt the string "baz" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("prefix") + # Use clientEncryption.encrypt() to encrypt the string "baz". + encrypted_value = await self._encrypt( + "baz", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), ) - encrypted_value = await self.client_encryption.encrypt( - "baz", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.PREFIXPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter: - value = await self.client_encrypted.db["prefix-suffix"].find_one( - {"$expr": {"$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value}}} + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + collection, + { + "$expr": { + "$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value} + } + }, ) # Assert that no documents are returned. self.assertIsNone(value) async def test_04_no_document_found_by_suffix(self): - # Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("suffix") + # Use clientEncryption.encrypt() to encrypt the string "foo". + encrypted_value = await self._encrypt( + "foo", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), ) - encrypted_value = await self.client_encryption.encrypt( - "foo", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.SUFFIXPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter: - value = await self.client_encrypted.db["prefix-suffix"].find_one( - {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}} + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + collection, + {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}, ) # Assert that no documents are returned. self.assertIsNone(value) async def test_05_can_find_a_document_by_substring(self): - # Use clientEncryption.encrypt() to encrypt the string "bar" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("substring") + # Use clientEncryption.encrypt() to encrypt the string "bar". + encrypted_value = await self._encrypt( + "bar", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, - substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2), + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), ) - encrypted_value = await self.client_encryption.encrypt( - "bar", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.SUBSTRINGPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.substring collection with the following filter: - value = await self.client_encrypted.db["substring"].find_one( + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + collection, { "$expr": { "$encStrContains": {"input": "$encryptedText", "substring": encrypted_value} } - } + }, ) - # Assert the following document is returned: - expected = {"_id": 0, "encryptedText": "foobarbaz"} - value.pop("__safeContent__", None) - self.assertEqual(value, expected) + # Assert the following document is returned. + self.assertEqual(value, {"_id": 0, "encryptedText": "foobarbaz"}) async def test_06_no_document_found_by_substring(self): - # Use clientEncryption.encrypt() to encrypt the string "qux" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("substring") + # Use clientEncryption.encrypt() to encrypt the string "qux". + encrypted_value = await self._encrypt( + "qux", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, - substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2), + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), ) - encrypted_value = await self.client_encryption.encrypt( - "qux", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.SUBSTRINGPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.substring collection with the following filter: - value = await self.client_encrypted.db["substring"].find_one( + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + collection, { "$expr": { "$encStrContains": {"input": "$encryptedText", "substring": encrypted_value} } - } + }, ) # Assert that no documents are returned. self.assertIsNone(value) @@ -3550,25 +3636,164 @@ async def test_06_no_document_found_by_substring(self): async def test_07_contentionFactor_is_required(self): from pymongocrypt.errors import MongoCryptError - # Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts: - text_opts = TextOpts( - case_sensitive=True, - diacritic_sensitive=True, - prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), - ) + self._require_ga(1, 19, 0) + # Use clientEncryption.encrypt() to encrypt the string "foo" without contentionFactor. with self.assertRaises(EncryptionError) as ctx: await self.client_encryption.encrypt( "foo", key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.PREFIXPREVIEW, - text_opts=text_opts, + algorithm=Algorithm.STRING, + query_type=QueryType.PREFIX, + string_opts=StringOpts( + case_sensitive=True, + diacritic_sensitive=True, + prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ), ) - # Expect an error from libmongocrypt with a message containing the string: "contention factor is required for textPreview algorithm". + # Expect an error from libmongocrypt with a message containing the + # string: "contention factor is required for string algorithm". self.assertIsInstance(ctx.exception.cause, MongoCryptError) - self.assertEqual( - str(ctx.exception), "contention factor is required for textPreview algorithm" + self.assertIn("contention factor is required for string algorithm", str(ctx.exception)) + + async def test_08_case_insensitive_prefix_and_suffix(self): + # This is a regression test for DRIVERS-3470. + self._require_ga(1, 19, 0) + # Use autoEncryptedClient to insert the following document. + await self._insert( + "prefix-suffix-ci-di", {"encryptedText": "BingQiLin"}, self.client_auto_encrypted + ) + # Use clientEncryption.encrypt() to encrypt the string "bing". + encrypted_value = await self._encrypt( + "bing", + query_type=QueryType.PREFIX, + case_sensitive=False, + diacritic_sensitive=False, + prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + "prefix-suffix-ci-di", + { + "$expr": { + "$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value} + } + }, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "BingQiLin") + + # Use clientEncryption.encrypt() to encrypt the string "lin". + encrypted_value = await self._encrypt( + "lin", + query_type=QueryType.SUFFIX, + case_sensitive=False, + diacritic_sensitive=False, + suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + "prefix-suffix-ci-di", + {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "BingQiLin") + + async def test_09_diacritic_insensitive_prefix_and_suffix(self): + # This is a regression test for DRIVERS-3470. + self._require_ga(1, 19, 0) + # Use autoEncryptedClient to insert the following document. + await self._insert( + "prefix-suffix-ci-di", {"encryptedText": "cafébarbäz"}, self.client_auto_encrypted + ) + # Use clientEncryption.encrypt() to encrypt the string "cafe". + encrypted_value = await self._encrypt( + "cafe", + query_type=QueryType.PREFIX, + case_sensitive=False, + diacritic_sensitive=False, + prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + "prefix-suffix-ci-di", + { + "$expr": { + "$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value} + } + }, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "cafébarbäz") + + # Use clientEncryption.encrypt() to encrypt the string "baz". + encrypted_value = await self._encrypt( + "baz", + query_type=QueryType.SUFFIX, + case_sensitive=False, + diacritic_sensitive=False, + suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + "prefix-suffix-ci-di", + {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}, ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "cafébarbäz") + + async def test_10_case_insensitive_substring(self): + # This is a regression test for DRIVERS-3470. + self._require_ga(1, 20, 0) + # Use autoEncryptedClient to insert the following document. + await self._insert( + "substring-ci-di", {"encryptedText": "FooBarBaz"}, self.client_auto_encrypted + ) + # Use clientEncryption.encrypt() to encrypt the string "bar". + encrypted_value = await self._encrypt( + "bar", + query_type=QueryType.SUBSTRING, + case_sensitive=False, + diacritic_sensitive=False, + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + "substring-ci-di", + { + "$expr": { + "$encStrContains": {"input": "$encryptedText", "substring": encrypted_value} + } + }, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "FooBarBaz") + + async def test_11_diacritic_insensitive_substring(self): + # This is a regression test for DRIVERS-3470. + self._require_ga(1, 20, 0) + # Use autoEncryptedClient to insert the following document. + await self._insert( + "substring-ci-di", {"encryptedText": "foocafébaz"}, self.client_auto_encrypted + ) + # Use clientEncryption.encrypt() to encrypt the string "cafe". + encrypted_value = await self._encrypt( + "cafe", + query_type=QueryType.SUBSTRING, + case_sensitive=False, + diacritic_sensitive=False, + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = await self._find( + "substring-ci-di", + { + "$expr": { + "$encStrContains": {"input": "$encryptedText", "substring": encrypted_value} + } + }, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "foocafébaz") def start_mongocryptd(port) -> None: diff --git a/test/test_encryption.py b/test/test_encryption.py index e567826f2a..8d71903961 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -59,7 +59,13 @@ from bson.son import SON from pymongo import ReadPreference from pymongo.cursor_shared import CursorType -from pymongo.encryption_options import _HAVE_PYMONGOCRYPT, AutoEncryptionOpts, RangeOpts, TextOpts +from pymongo.encryption_options import ( + _HAVE_PYMONGOCRYPT, + AutoEncryptionOpts, + RangeOpts, + StringOpts, + TextOpts, +) from pymongo.errors import ( AutoReconnect, BulkWriteError, @@ -104,6 +110,7 @@ is_greenthread_patched, wait_until, ) +from test.version import Version _IS_SYNC = True @@ -229,6 +236,32 @@ def test_kwargs(self): self.assertEqual(get_client_opts(client).auto_encryption_opts, opts) +class TestStringOptsDeprecation(PyMongoTestCase): + def test_text_opts_is_deprecated(self): + with self.assertWarns(DeprecationWarning): + opts = TextOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + self.assertIsInstance(opts, StringOpts) + self.assertEqual( + StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}).document, + opts.document, + ) + + def test_resolve_string_opts(self): + string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + self.assertIsNone(encryption._resolve_string_opts(None, None)) + self.assertIs(encryption._resolve_string_opts(string_opts, None), string_opts) + + def test_resolve_string_opts_text_opts_is_deprecated(self): + string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + with self.assertWarns(DeprecationWarning): + self.assertIs(encryption._resolve_string_opts(None, string_opts), string_opts) + + def test_resolve_string_opts_rejects_both(self): + string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + with self.assertRaises(ConfigurationError): + encryption._resolve_string_opts(string_opts, string_opts) + + class EncryptionIntegrationTest(IntegrationTest): """Base class for encryption integration tests.""" @@ -3295,13 +3328,21 @@ def test_collection_name_collision(self): self.assertIsInstance(exc.exception.encrypted_fields["fields"][0]["keyId"], Binary) -# https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-text-explicit-encryption -@unittest.skip("PYTHON-5799 need to add support for the new query type") -class TestExplicitTextEncryptionProse(EncryptionIntegrationTest): +def _libmongocrypt_at_least(*version): + """Return True if the installed libmongocrypt is at least `version`.""" + from pymongocrypt import libmongocrypt_version + + return Version.from_string(libmongocrypt_version()) >= Version(*version) + + +# https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-string-explicit-encryption +class TestStringExplicitEncryptionProse(EncryptionIntegrationTest): + # The GA collections require server 9.0+, the preview collections require + # server pre-9.0. Test Setup encrypts with the "String" algorithm, which + # requires libmongocrypt 1.19.0+. @client_context.require_no_standalone @client_context.require_version_min(8, 2, -1) - @client_context.require_version_max(8, 99, 99) - @client_context.require_libmongocrypt_min(1, 15, 1) + @client_context.require_libmongocrypt_min(1, 19, 0) @client_context.require_pymongocrypt_min(1, 16, 0) def setUp(self): super().setUp() @@ -3321,210 +3362,255 @@ def setUp(self): self.client, OPTS, ) - # Create a MongoClient named encryptedClient with these AutoEncryptionOpts. - opts = AutoEncryptionOpts( - self.kms_providers, - "keyvault.datakeys", - bypass_query_analysis=True, + # Create a MongoClient named explicitEncryptedClient with these AutoEncryptionOpts. + self.client_encrypted = self.rs_or_single_client( + auto_encryption_opts=AutoEncryptionOpts( + self.kms_providers, + "keyvault.datakeys", + bypass_query_analysis=True, + ) + ) + # Create a MongoClient named autoEncryptedClient with these AutoEncryptionOpts. + self.client_auto_encrypted = self.rs_or_single_client( + auto_encryption_opts=AutoEncryptionOpts( + self.kms_providers, + "keyvault.datakeys", + ) ) - self.client_encrypted = self.rs_or_single_client(auto_encryption_opts=opts) - # Using QE CreateCollection() and Collection.Drop(), drop and create the following collections with majority write concern: - # db.prefix-suffix using the encryptedFields option set to the contents of encryptedFields-prefix-suffix.json. + # The GA query types ("prefix", "suffix", "substring") require server + # 9.0+, which in turn dropped the preview query types. + self.is_ga = client_context.version.at_least(9, 0, -1) + + # Using QE CreateCollection() and Collection.Drop(), drop and create the + # collections with majority write concern. db = self.client_encrypted.db - db.drop_collection("prefix-suffix") - encrypted_fields = json_data("etc", "data", "encryptedFields-prefix-suffix.json") - self.client_encryption.create_encrypted_collection( - db, "prefix-suffix", kms_provider="local", encrypted_fields=encrypted_fields - ) - # db.substring using the encryptedFields option set to the contents of encryptedFields-substring.json. - db.drop_collection("substring") - encrypted_fields = json_data("etc", "data", "encryptedFields-substring.json") - self.client_encryption.create_encrypted_collection( - db, "substring", kms_provider="local", encrypted_fields=encrypted_fields - ) + if self.is_ga: + collections = ["prefix-suffix", "prefix-suffix-ci-di", "substring", "substring-ci-di"] + else: + collections = ["prefix-suffix-preview", "substring-preview"] + for name in collections: + db.drop_collection(name) + self.client_encryption.create_encrypted_collection( + db, + name, + kms_provider="local", + encrypted_fields=json_data("etc", "data", f"encryptedFields-{name}.json"), + ) # Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts. - text_opts = TextOpts( - case_sensitive=True, - diacritic_sensitive=True, - prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), - suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), - ) encrypted_value = self.client_encryption.encrypt( "foobarbaz", key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, + algorithm=Algorithm.STRING, contention_factor=0, - text_opts=text_opts, + string_opts=StringOpts( + case_sensitive=True, + diacritic_sensitive=True, + prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), + suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ), ) - # Use encryptedClient to insert the following document into db.prefix-suffix with majority write concern. - coll = self.client_encrypted.db["prefix-suffix"].with_options( - write_concern=WriteConcern(w="majority") + # Use explicitEncryptedClient to insert the following document into + # db.prefix-suffix (if created) and db.prefix-suffix-preview (if created) + # with majority write concern. + self._insert( + "prefix-suffix" if self.is_ga else "prefix-suffix-preview", + {"_id": 0, "encryptedText": encrypted_value}, ) - coll.insert_one({"_id": 0, "encryptedText": encrypted_value}) # Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts. - text_opts = TextOpts( - case_sensitive=True, - diacritic_sensitive=True, - substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2), - ) encrypted_value = self.client_encryption.encrypt( "foobarbaz", key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, + algorithm=Algorithm.STRING, contention_factor=0, - text_opts=text_opts, + string_opts=StringOpts( + case_sensitive=True, + diacritic_sensitive=True, + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), + ), ) - # Use encryptedClient to insert the following document into db.substring with majority write concern. - coll = self.client_encrypted.db["substring"].with_options( - write_concern=WriteConcern(w="majority") + # Use explicitEncryptedClient to insert the following document into + # db.substring (if created) and db.substring-preview (if created) with + # majority write concern. + self._insert( + "substring" if self.is_ga else "substring-preview", + {"_id": 0, "encryptedText": encrypted_value}, + ) + + def _insert(self, collection, document, client=None): + """Insert a document with majority write concern.""" + client = client or self.client_encrypted + coll = client.db[collection].with_options(write_concern=WriteConcern(w="majority")) + coll.insert_one(document) + + def _params(self, kind): + """Return the (query_type, collection) pair to run a case against. + + Each case runs against the GA query type on server 9.0+ and against the + preview query type on earlier servers, skipping when the installed + libmongocrypt is too old for the applicable variant. + """ + if kind == "substring": + base, ga_req, preview_req = "substring", (1, 20, 0), (1, 18, 1) + else: + base, ga_req, preview_req = "prefix-suffix", (1, 19, 0), (1, 19, 1) + if self.is_ga: + query_type, collection, required = kind, base, ga_req + else: + query_type, collection, required = f"{kind}Preview", f"{base}-preview", preview_req + if not _libmongocrypt_at_least(*required): + raise unittest.SkipTest( + f"queryType={query_type} requires libmongocrypt {'.'.join(map(str, required))}+" + ) + return query_type, collection + + def _require_ga(self, *libmongocrypt_version): + """Skip a case that only applies to the GA query types.""" + if not self.is_ga: + raise unittest.SkipTest("requires server 9.0+") + if not _libmongocrypt_at_least(*libmongocrypt_version): + raise unittest.SkipTest( + f"requires libmongocrypt {'.'.join(map(str, libmongocrypt_version))}+" + ) + + def _encrypt(self, value, query_type=None, **string_opts): + return self.client_encryption.encrypt( + value, + key_id=self.key1_id, + algorithm=Algorithm.STRING, + query_type=query_type, + contention_factor=0, + string_opts=StringOpts(**string_opts), ) - coll.insert_one({"_id": 0, "encryptedText": encrypted_value}) + + def _find(self, collection, filter): + value = self.client_encrypted.db[collection].find_one(filter) + if value is not None: + value.pop("__safeContent__", None) + return value def test_01_can_find_a_document_by_prefix(self): - # Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts. - text_opts = TextOpts( + query_type, collection = self._params("prefix") + # Use clientEncryption.encrypt() to encrypt the string "foo". + encrypted_value = self._encrypt( + "foo", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), ) - encrypted_value = self.client_encryption.encrypt( - "foo", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.PREFIXPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter. - value = self.client_encrypted.db["prefix-suffix"].find_one( - {"$expr": {"$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value}}} + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + collection, + { + "$expr": { + "$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value} + } + }, ) # Assert the following document is returned. - expected = {"_id": 0, "encryptedText": "foobarbaz"} - value.pop("__safeContent__", None) - self.assertEqual(value, expected) + self.assertEqual(value, {"_id": 0, "encryptedText": "foobarbaz"}) def test_02_can_find_a_document_by_suffix(self): - # Use clientEncryption.encrypt() to encrypt the string "baz" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("suffix") + # Use clientEncryption.encrypt() to encrypt the string "baz". + encrypted_value = self._encrypt( + "baz", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), ) - encrypted_value = self.client_encryption.encrypt( - "baz", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.SUFFIXPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter: - value = self.client_encrypted.db["prefix-suffix"].find_one( - {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}} + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + collection, + {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}, ) # Assert the following document is returned. - expected = {"_id": 0, "encryptedText": "foobarbaz"} - value.pop("__safeContent__", None) - self.assertEqual(value, expected) + self.assertEqual(value, {"_id": 0, "encryptedText": "foobarbaz"}) def test_03_no_document_found_by_prefix(self): - # Use clientEncryption.encrypt() to encrypt the string "baz" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("prefix") + # Use clientEncryption.encrypt() to encrypt the string "baz". + encrypted_value = self._encrypt( + "baz", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), ) - encrypted_value = self.client_encryption.encrypt( - "baz", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.PREFIXPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter: - value = self.client_encrypted.db["prefix-suffix"].find_one( - {"$expr": {"$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value}}} + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + collection, + { + "$expr": { + "$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value} + } + }, ) # Assert that no documents are returned. self.assertIsNone(value) def test_04_no_document_found_by_suffix(self): - # Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("suffix") + # Use clientEncryption.encrypt() to encrypt the string "foo". + encrypted_value = self._encrypt( + "foo", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), ) - encrypted_value = self.client_encryption.encrypt( - "foo", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.SUFFIXPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.prefix-suffix collection with the following filter: - value = self.client_encrypted.db["prefix-suffix"].find_one( - {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}} + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + collection, + {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}, ) # Assert that no documents are returned. self.assertIsNone(value) def test_05_can_find_a_document_by_substring(self): - # Use clientEncryption.encrypt() to encrypt the string "bar" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("substring") + # Use clientEncryption.encrypt() to encrypt the string "bar". + encrypted_value = self._encrypt( + "bar", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, - substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2), + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), ) - encrypted_value = self.client_encryption.encrypt( - "bar", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.SUBSTRINGPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.substring collection with the following filter: - value = self.client_encrypted.db["substring"].find_one( + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + collection, { "$expr": { "$encStrContains": {"input": "$encryptedText", "substring": encrypted_value} } - } + }, ) - # Assert the following document is returned: - expected = {"_id": 0, "encryptedText": "foobarbaz"} - value.pop("__safeContent__", None) - self.assertEqual(value, expected) + # Assert the following document is returned. + self.assertEqual(value, {"_id": 0, "encryptedText": "foobarbaz"}) def test_06_no_document_found_by_substring(self): - # Use clientEncryption.encrypt() to encrypt the string "qux" with the following EncryptOpts: - text_opts = TextOpts( + query_type, collection = self._params("substring") + # Use clientEncryption.encrypt() to encrypt the string "qux". + encrypted_value = self._encrypt( + "qux", + query_type=query_type, case_sensitive=True, diacritic_sensitive=True, - substring=dict(strMaxLength=10, strMaxQueryLength=10, strMinQueryLength=2), + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), ) - encrypted_value = self.client_encryption.encrypt( - "qux", - key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.SUBSTRINGPREVIEW, - contention_factor=0, - text_opts=text_opts, - ) - # Use encryptedClient to run a "find" operation on the db.substring collection with the following filter: - value = self.client_encrypted.db["substring"].find_one( + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + collection, { "$expr": { "$encStrContains": {"input": "$encryptedText", "substring": encrypted_value} } - } + }, ) # Assert that no documents are returned. self.assertIsNone(value) @@ -3532,25 +3618,160 @@ def test_06_no_document_found_by_substring(self): def test_07_contentionFactor_is_required(self): from pymongocrypt.errors import MongoCryptError - # Use clientEncryption.encrypt() to encrypt the string "foo" with the following EncryptOpts: - text_opts = TextOpts( - case_sensitive=True, - diacritic_sensitive=True, - prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), - ) + self._require_ga(1, 19, 0) + # Use clientEncryption.encrypt() to encrypt the string "foo" without contentionFactor. with self.assertRaises(EncryptionError) as ctx: self.client_encryption.encrypt( "foo", key_id=self.key1_id, - algorithm=Algorithm.TEXTPREVIEW, - query_type=QueryType.PREFIXPREVIEW, - text_opts=text_opts, + algorithm=Algorithm.STRING, + query_type=QueryType.PREFIX, + string_opts=StringOpts( + case_sensitive=True, + diacritic_sensitive=True, + prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ), ) - # Expect an error from libmongocrypt with a message containing the string: "contention factor is required for textPreview algorithm". + # Expect an error from libmongocrypt with a message containing the + # string: "contention factor is required for string algorithm". self.assertIsInstance(ctx.exception.cause, MongoCryptError) - self.assertEqual( - str(ctx.exception), "contention factor is required for textPreview algorithm" + self.assertIn("contention factor is required for string algorithm", str(ctx.exception)) + + def test_08_case_insensitive_prefix_and_suffix(self): + # This is a regression test for DRIVERS-3470. + self._require_ga(1, 19, 0) + # Use autoEncryptedClient to insert the following document. + self._insert( + "prefix-suffix-ci-di", {"encryptedText": "BingQiLin"}, self.client_auto_encrypted + ) + # Use clientEncryption.encrypt() to encrypt the string "bing". + encrypted_value = self._encrypt( + "bing", + query_type=QueryType.PREFIX, + case_sensitive=False, + diacritic_sensitive=False, + prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), ) + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + "prefix-suffix-ci-di", + { + "$expr": { + "$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value} + } + }, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "BingQiLin") + + # Use clientEncryption.encrypt() to encrypt the string "lin". + encrypted_value = self._encrypt( + "lin", + query_type=QueryType.SUFFIX, + case_sensitive=False, + diacritic_sensitive=False, + suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + "prefix-suffix-ci-di", + {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "BingQiLin") + + def test_09_diacritic_insensitive_prefix_and_suffix(self): + # This is a regression test for DRIVERS-3470. + self._require_ga(1, 19, 0) + # Use autoEncryptedClient to insert the following document. + self._insert( + "prefix-suffix-ci-di", {"encryptedText": "cafébarbäz"}, self.client_auto_encrypted + ) + # Use clientEncryption.encrypt() to encrypt the string "cafe". + encrypted_value = self._encrypt( + "cafe", + query_type=QueryType.PREFIX, + case_sensitive=False, + diacritic_sensitive=False, + prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + "prefix-suffix-ci-di", + { + "$expr": { + "$encStrStartsWith": {"input": "$encryptedText", "prefix": encrypted_value} + } + }, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "cafébarbäz") + + # Use clientEncryption.encrypt() to encrypt the string "baz". + encrypted_value = self._encrypt( + "baz", + query_type=QueryType.SUFFIX, + case_sensitive=False, + diacritic_sensitive=False, + suffix=dict(strMaxQueryLength=10, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + "prefix-suffix-ci-di", + {"$expr": {"$encStrEndsWith": {"input": "$encryptedText", "suffix": encrypted_value}}}, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "cafébarbäz") + + def test_10_case_insensitive_substring(self): + # This is a regression test for DRIVERS-3470. + self._require_ga(1, 20, 0) + # Use autoEncryptedClient to insert the following document. + self._insert("substring-ci-di", {"encryptedText": "FooBarBaz"}, self.client_auto_encrypted) + # Use clientEncryption.encrypt() to encrypt the string "bar". + encrypted_value = self._encrypt( + "bar", + query_type=QueryType.SUBSTRING, + case_sensitive=False, + diacritic_sensitive=False, + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + "substring-ci-di", + { + "$expr": { + "$encStrContains": {"input": "$encryptedText", "substring": encrypted_value} + } + }, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "FooBarBaz") + + def test_11_diacritic_insensitive_substring(self): + # This is a regression test for DRIVERS-3470. + self._require_ga(1, 20, 0) + # Use autoEncryptedClient to insert the following document. + self._insert("substring-ci-di", {"encryptedText": "foocafébaz"}, self.client_auto_encrypted) + # Use clientEncryption.encrypt() to encrypt the string "cafe". + encrypted_value = self._encrypt( + "cafe", + query_type=QueryType.SUBSTRING, + case_sensitive=False, + diacritic_sensitive=False, + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), + ) + # Use explicitEncryptedClient to run a "find" operation. + value = self._find( + "substring-ci-di", + { + "$expr": { + "$encStrContains": {"input": "$encryptedText", "substring": encrypted_value} + } + }, + ) + # Assert the following document is returned. + self.assertEqual(value["encryptedText"], "foocafébaz") def start_mongocryptd(port) -> None: From 4d58951c15a1ae18bb30b069504fbbf7eba9939b Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 17 Aug 2026 16:04:07 -0400 Subject: [PATCH 2/8] PYTHON-5909 Keep TextOpts re-exported and gate string queries per query type - Re-export the deprecated TextOpts from pymongo.encryption so 'from pymongo.encryption import TextOpts' keeps working, with a regression test. - Replace the hardcoded libmongocrypt version tuples in the prose tests with a single _STRING_QUERY_MIN_LIBMONGOCRYPT table keyed by query type, and gate each case on the query types it exercises. - Correct the changelog: prefix/suffix need libmongocrypt 1.19.0+, substring needs 1.20.0+. --- doc/changelog.rst | 5 ++- pymongo/asynchronous/encryption.py | 3 ++ pymongo/synchronous/encryption.py | 3 ++ test/asynchronous/test_encryption.py | 65 +++++++++++++++++++--------- test/test_encryption.py | 65 +++++++++++++++++++--------- 5 files changed, 97 insertions(+), 44 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 0434186ce1..dcea408868 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -32,8 +32,9 @@ PyMongo 4.18 brings a number of changes including: ``SSL_CERT_FILE``/``SSL_CERT_DIR`` were merged with, rather than replacing, the OS/certifi certificate store. - Added general availability support for Queryable Encryption prefix, suffix, - and substring queries against MongoDB 9.0+, which requires libmongocrypt - 1.20.0 or later: + and substring queries against MongoDB 9.0+. Prefix and suffix queries require + libmongocrypt 1.19.0 or later; substring queries require libmongocrypt 1.20.0 + or later: - Added :attr:`~pymongo.encryption.Algorithm.STRING` and :class:`~pymongo.encryption_options.StringOpts`, replacing diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index e413077470..49b32edd93 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -67,6 +67,9 @@ AutoEncryptionOpts, RangeOpts, StringOpts, + # Re-exported for backwards compatibility: TextOpts is deprecated but must + # remain importable from this module until it is removed. + TextOpts, # noqa: F401 check_min_pymongocrypt, ) from pymongo.errors import ( diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index 33669bd525..5516237146 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -62,6 +62,9 @@ AutoEncryptionOpts, RangeOpts, StringOpts, + # Re-exported for backwards compatibility: TextOpts is deprecated but must + # remain importable from this module until it is removed. + TextOpts, # noqa: F401 check_min_pymongocrypt, ) from pymongo.errors import ( diff --git a/test/asynchronous/test_encryption.py b/test/asynchronous/test_encryption.py index 27c85cb984..4fc3cd0d89 100644 --- a/test/asynchronous/test_encryption.py +++ b/test/asynchronous/test_encryption.py @@ -237,6 +237,11 @@ async def test_kwargs(self): class TestStringOptsDeprecation(AsyncPyMongoTestCase): + def test_text_opts_is_still_re_exported(self): + # TextOpts is deprecated, not removed, so it must stay importable from + # the encryption module for the deprecation period. + self.assertIs(encryption.TextOpts, TextOpts) + def test_text_opts_is_deprecated(self): with self.assertWarns(DeprecationWarning): opts = TextOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) @@ -3353,6 +3358,20 @@ def _libmongocrypt_at_least(*version): return Version.from_string(libmongocrypt_version()) >= Version(*version) +# The minimum libmongocrypt version required by each string query type, +# declared in one place so the test gates and the changelog agree. Support +# landed per query type rather than all at once: prefix and suffix in 1.19.0, +# substring in 1.20.0. +_STRING_QUERY_MIN_LIBMONGOCRYPT = { + "prefix": (1, 19, 0), + "suffix": (1, 19, 0), + "substring": (1, 20, 0), + "prefixPreview": (1, 19, 1), + "suffixPreview": (1, 19, 1), + "substringPreview": (1, 19, 1), +} + + # https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-string-explicit-encryption class TestStringExplicitEncryptionProse(AsyncEncryptionIntegrationTest): # The GA collections require server 9.0+, the preview collections require @@ -3463,6 +3482,14 @@ async def _insert(self, collection, document, client=None): coll = client.db[collection].with_options(write_concern=WriteConcern(w="majority")) await coll.insert_one(document) + def _require_query_type(self, query_type): + """Skip unless the installed libmongocrypt supports `query_type`.""" + required = _STRING_QUERY_MIN_LIBMONGOCRYPT[query_type] + if not _libmongocrypt_at_least(*required): + raise unittest.SkipTest( + f"queryType={query_type} requires libmongocrypt {'.'.join(map(str, required))}+" + ) + def _params(self, kind): """Return the (query_type, collection) pair to run a case against. @@ -3470,28 +3497,24 @@ def _params(self, kind): preview query type on earlier servers, skipping when the installed libmongocrypt is too old for the applicable variant. """ - if kind == "substring": - base, ga_req, preview_req = "substring", (1, 20, 0), (1, 18, 1) - else: - base, ga_req, preview_req = "prefix-suffix", (1, 19, 0), (1, 19, 1) + base = "substring" if kind == "substring" else "prefix-suffix" if self.is_ga: - query_type, collection, required = kind, base, ga_req + query_type, collection = kind, base else: - query_type, collection, required = f"{kind}Preview", f"{base}-preview", preview_req - if not _libmongocrypt_at_least(*required): - raise unittest.SkipTest( - f"queryType={query_type} requires libmongocrypt {'.'.join(map(str, required))}+" - ) + query_type, collection = f"{kind}Preview", f"{base}-preview" + self._require_query_type(query_type) return query_type, collection - def _require_ga(self, *libmongocrypt_version): - """Skip a case that only applies to the GA query types.""" + def _require_ga(self, *query_types): + """Skip a case that only applies to the GA query types. + + Gates on each query type the case exercises, since substring support + landed in a later libmongocrypt than prefix and suffix. + """ if not self.is_ga: raise unittest.SkipTest("requires server 9.0+") - if not _libmongocrypt_at_least(*libmongocrypt_version): - raise unittest.SkipTest( - f"requires libmongocrypt {'.'.join(map(str, libmongocrypt_version))}+" - ) + for query_type in query_types: + self._require_query_type(query_type) async def _encrypt(self, value, query_type=None, **string_opts): return await self.client_encryption.encrypt( @@ -3636,7 +3659,7 @@ async def test_06_no_document_found_by_substring(self): async def test_07_contentionFactor_is_required(self): from pymongocrypt.errors import MongoCryptError - self._require_ga(1, 19, 0) + self._require_ga("prefix") # Use clientEncryption.encrypt() to encrypt the string "foo" without contentionFactor. with self.assertRaises(EncryptionError) as ctx: await self.client_encryption.encrypt( @@ -3657,7 +3680,7 @@ async def test_07_contentionFactor_is_required(self): async def test_08_case_insensitive_prefix_and_suffix(self): # This is a regression test for DRIVERS-3470. - self._require_ga(1, 19, 0) + self._require_ga("prefix", "suffix") # Use autoEncryptedClient to insert the following document. await self._insert( "prefix-suffix-ci-di", {"encryptedText": "BingQiLin"}, self.client_auto_encrypted @@ -3700,7 +3723,7 @@ async def test_08_case_insensitive_prefix_and_suffix(self): async def test_09_diacritic_insensitive_prefix_and_suffix(self): # This is a regression test for DRIVERS-3470. - self._require_ga(1, 19, 0) + self._require_ga("prefix", "suffix") # Use autoEncryptedClient to insert the following document. await self._insert( "prefix-suffix-ci-di", {"encryptedText": "cafébarbäz"}, self.client_auto_encrypted @@ -3743,7 +3766,7 @@ async def test_09_diacritic_insensitive_prefix_and_suffix(self): async def test_10_case_insensitive_substring(self): # This is a regression test for DRIVERS-3470. - self._require_ga(1, 20, 0) + self._require_ga("substring") # Use autoEncryptedClient to insert the following document. await self._insert( "substring-ci-di", {"encryptedText": "FooBarBaz"}, self.client_auto_encrypted @@ -3770,7 +3793,7 @@ async def test_10_case_insensitive_substring(self): async def test_11_diacritic_insensitive_substring(self): # This is a regression test for DRIVERS-3470. - self._require_ga(1, 20, 0) + self._require_ga("substring") # Use autoEncryptedClient to insert the following document. await self._insert( "substring-ci-di", {"encryptedText": "foocafébaz"}, self.client_auto_encrypted diff --git a/test/test_encryption.py b/test/test_encryption.py index 8d71903961..71bf1ff86d 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -237,6 +237,11 @@ def test_kwargs(self): class TestStringOptsDeprecation(PyMongoTestCase): + def test_text_opts_is_still_re_exported(self): + # TextOpts is deprecated, not removed, so it must stay importable from + # the encryption module for the deprecation period. + self.assertIs(encryption.TextOpts, TextOpts) + def test_text_opts_is_deprecated(self): with self.assertWarns(DeprecationWarning): opts = TextOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) @@ -3335,6 +3340,20 @@ def _libmongocrypt_at_least(*version): return Version.from_string(libmongocrypt_version()) >= Version(*version) +# The minimum libmongocrypt version required by each string query type, +# declared in one place so the test gates and the changelog agree. Support +# landed per query type rather than all at once: prefix and suffix in 1.19.0, +# substring in 1.20.0. +_STRING_QUERY_MIN_LIBMONGOCRYPT = { + "prefix": (1, 19, 0), + "suffix": (1, 19, 0), + "substring": (1, 20, 0), + "prefixPreview": (1, 19, 1), + "suffixPreview": (1, 19, 1), + "substringPreview": (1, 19, 1), +} + + # https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-string-explicit-encryption class TestStringExplicitEncryptionProse(EncryptionIntegrationTest): # The GA collections require server 9.0+, the preview collections require @@ -3445,6 +3464,14 @@ def _insert(self, collection, document, client=None): coll = client.db[collection].with_options(write_concern=WriteConcern(w="majority")) coll.insert_one(document) + def _require_query_type(self, query_type): + """Skip unless the installed libmongocrypt supports `query_type`.""" + required = _STRING_QUERY_MIN_LIBMONGOCRYPT[query_type] + if not _libmongocrypt_at_least(*required): + raise unittest.SkipTest( + f"queryType={query_type} requires libmongocrypt {'.'.join(map(str, required))}+" + ) + def _params(self, kind): """Return the (query_type, collection) pair to run a case against. @@ -3452,28 +3479,24 @@ def _params(self, kind): preview query type on earlier servers, skipping when the installed libmongocrypt is too old for the applicable variant. """ - if kind == "substring": - base, ga_req, preview_req = "substring", (1, 20, 0), (1, 18, 1) - else: - base, ga_req, preview_req = "prefix-suffix", (1, 19, 0), (1, 19, 1) + base = "substring" if kind == "substring" else "prefix-suffix" if self.is_ga: - query_type, collection, required = kind, base, ga_req + query_type, collection = kind, base else: - query_type, collection, required = f"{kind}Preview", f"{base}-preview", preview_req - if not _libmongocrypt_at_least(*required): - raise unittest.SkipTest( - f"queryType={query_type} requires libmongocrypt {'.'.join(map(str, required))}+" - ) + query_type, collection = f"{kind}Preview", f"{base}-preview" + self._require_query_type(query_type) return query_type, collection - def _require_ga(self, *libmongocrypt_version): - """Skip a case that only applies to the GA query types.""" + def _require_ga(self, *query_types): + """Skip a case that only applies to the GA query types. + + Gates on each query type the case exercises, since substring support + landed in a later libmongocrypt than prefix and suffix. + """ if not self.is_ga: raise unittest.SkipTest("requires server 9.0+") - if not _libmongocrypt_at_least(*libmongocrypt_version): - raise unittest.SkipTest( - f"requires libmongocrypt {'.'.join(map(str, libmongocrypt_version))}+" - ) + for query_type in query_types: + self._require_query_type(query_type) def _encrypt(self, value, query_type=None, **string_opts): return self.client_encryption.encrypt( @@ -3618,7 +3641,7 @@ def test_06_no_document_found_by_substring(self): def test_07_contentionFactor_is_required(self): from pymongocrypt.errors import MongoCryptError - self._require_ga(1, 19, 0) + self._require_ga("prefix") # Use clientEncryption.encrypt() to encrypt the string "foo" without contentionFactor. with self.assertRaises(EncryptionError) as ctx: self.client_encryption.encrypt( @@ -3639,7 +3662,7 @@ def test_07_contentionFactor_is_required(self): def test_08_case_insensitive_prefix_and_suffix(self): # This is a regression test for DRIVERS-3470. - self._require_ga(1, 19, 0) + self._require_ga("prefix", "suffix") # Use autoEncryptedClient to insert the following document. self._insert( "prefix-suffix-ci-di", {"encryptedText": "BingQiLin"}, self.client_auto_encrypted @@ -3682,7 +3705,7 @@ def test_08_case_insensitive_prefix_and_suffix(self): def test_09_diacritic_insensitive_prefix_and_suffix(self): # This is a regression test for DRIVERS-3470. - self._require_ga(1, 19, 0) + self._require_ga("prefix", "suffix") # Use autoEncryptedClient to insert the following document. self._insert( "prefix-suffix-ci-di", {"encryptedText": "cafébarbäz"}, self.client_auto_encrypted @@ -3725,7 +3748,7 @@ def test_09_diacritic_insensitive_prefix_and_suffix(self): def test_10_case_insensitive_substring(self): # This is a regression test for DRIVERS-3470. - self._require_ga(1, 20, 0) + self._require_ga("substring") # Use autoEncryptedClient to insert the following document. self._insert("substring-ci-di", {"encryptedText": "FooBarBaz"}, self.client_auto_encrypted) # Use clientEncryption.encrypt() to encrypt the string "bar". @@ -3750,7 +3773,7 @@ def test_10_case_insensitive_substring(self): def test_11_diacritic_insensitive_substring(self): # This is a regression test for DRIVERS-3470. - self._require_ga(1, 20, 0) + self._require_ga("substring") # Use autoEncryptedClient to insert the following document. self._insert("substring-ci-di", {"encryptedText": "foocafébaz"}, self.client_auto_encrypted) # Use clientEncryption.encrypt() to encrypt the string "cafe". From 71b091f01c45f46379c27fa15da3433750c540f5 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 17 Aug 2026 16:26:41 -0400 Subject: [PATCH 3/8] PYTHON-5909 Test preview string queries against released pymongocrypt on 8.0 Servers before 9.0 exercise the preview query types, which need the deprecated 'textPreview' algorithm: 'String' was only added in libmongocrypt 1.19.0. Pick the algorithm from the installed libmongocrypt version, lower the class gate to 1.18.1, and record the 1.19.0 hole where prefixPreview/suffixPreview were removed before being restored in 1.19.1. On EVG, pin MONGODB_VERSION=8.0 tasks to pymongocrypt<1.19 and use the libmongocrypt bundled in that wheel, so the preview path is tested against bindings users can actually install. --- .evergreen/scripts/setup_tests.py | 53 +++++++++++++++++----------- test/asynchronous/test_encryption.py | 46 +++++++++++++++++------- test/test_encryption.py | 46 +++++++++++++++++------- 3 files changed, 99 insertions(+), 46 deletions(-) diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index 1a41b3a8b0..90f700bc6b 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -353,29 +353,42 @@ def handle_test_env() -> None: UV_ARGS.append("--extra zstd") if test_name in ["encryption", "kms"]: - # Check for libmongocrypt download. - if not (ROOT / "libmongocrypt").exists(): - setup_libmongocrypt() + # The "String" algorithm and the GA prefix/suffix/substring query types + # need libmongocrypt 1.19.0+, which is only exercised against MongoDB + # 9.0+. Servers before 9.0 test the preview query types instead, which + # need the "textPreview" algorithm, so pin to the released pymongocrypt + # and use the libmongocrypt bundled in its wheel rather than the + # unreleased master build. + use_released_pymongocrypt = os.environ.get("MONGODB_VERSION", "").startswith("8.") + + if not use_released_pymongocrypt: + # Check for libmongocrypt download. + if not (ROOT / "libmongocrypt").exists(): + setup_libmongocrypt() if not opts.test_min_deps: - UV_ARGS.append( - "--with pymongocrypt@git+https://github.com/mongodb/libmongocrypt@master#subdirectory=bindings/python" - ) - - # Use the nocrypto build to avoid dependency issues with older windows/python versions. - BASE = ROOT / "libmongocrypt/nocrypto" - if PLATFORM == "linux": - if (BASE / "lib/libmongocrypt.so").exists(): - PYMONGOCRYPT_LIB = BASE / "lib/libmongocrypt.so" + if use_released_pymongocrypt: + UV_ARGS.append("--with pymongocrypt<1.19") else: - PYMONGOCRYPT_LIB = BASE / "lib64/libmongocrypt.so" - elif PLATFORM == "darwin": - PYMONGOCRYPT_LIB = BASE / "lib/libmongocrypt.dylib" - else: - PYMONGOCRYPT_LIB = BASE / "bin/mongocrypt.dll" - if not PYMONGOCRYPT_LIB.exists(): - raise RuntimeError("Cannot find libmongocrypt shared object file") - write_env("PYMONGOCRYPT_LIB", PYMONGOCRYPT_LIB.as_posix()) + UV_ARGS.append( + "--with pymongocrypt@git+https://github.com/mongodb/libmongocrypt@master#subdirectory=bindings/python" + ) + + if not use_released_pymongocrypt: + # Use the nocrypto build to avoid dependency issues with older windows/python versions. + BASE = ROOT / "libmongocrypt/nocrypto" + if PLATFORM == "linux": + if (BASE / "lib/libmongocrypt.so").exists(): + PYMONGOCRYPT_LIB = BASE / "lib/libmongocrypt.so" + else: + PYMONGOCRYPT_LIB = BASE / "lib64/libmongocrypt.so" + elif PLATFORM == "darwin": + PYMONGOCRYPT_LIB = BASE / "lib/libmongocrypt.dylib" + else: + PYMONGOCRYPT_LIB = BASE / "bin/mongocrypt.dll" + if not PYMONGOCRYPT_LIB.exists(): + raise RuntimeError("Cannot find libmongocrypt shared object file") + write_env("PYMONGOCRYPT_LIB", PYMONGOCRYPT_LIB.as_posix()) # PATH is updated by configure-env.sh for access to mongocryptd. if test_name == "encryption": diff --git a/test/asynchronous/test_encryption.py b/test/asynchronous/test_encryption.py index d475caa849..585eef8c83 100644 --- a/test/asynchronous/test_encryption.py +++ b/test/asynchronous/test_encryption.py @@ -3357,28 +3357,37 @@ def _libmongocrypt_at_least(*version): return Version.from_string(libmongocrypt_version()) >= Version(*version) -# The minimum libmongocrypt version required by each string query type, -# declared in one place so the test gates and the changelog agree. Support -# landed per query type rather than all at once: prefix and suffix in 1.19.0, -# substring in 1.20.0. +# The minimum libmongocrypt version required by each string query type, declared +# in one place so the test gates and the changelog agree. Support landed per +# query type rather than all at once (see the libmongocrypt changelog): +# 1.18.1 - fixes caseSensitive/diacriticSensitive handling for "textPreview". +# 1.19.0 - the "string" algorithm replaces "textPreview"; prefix and suffix go +# stable; prefixPreview and suffixPreview are removed. +# 1.19.1 - prefixPreview and suffixPreview are restored. +# 1.20.0 - substring goes stable. _STRING_QUERY_MIN_LIBMONGOCRYPT = { "prefix": (1, 19, 0), "suffix": (1, 19, 0), "substring": (1, 20, 0), - "prefixPreview": (1, 19, 1), - "suffixPreview": (1, 19, 1), - "substringPreview": (1, 19, 1), + "prefixPreview": (1, 18, 1), + "suffixPreview": (1, 18, 1), + "substringPreview": (1, 18, 1), } +# prefixPreview and suffixPreview were removed in 1.19.0 and restored in 1.19.1, +# so that one release is a hole rather than a floor. +_PREVIEW_REMOVED_IN = (1, 19, 0) + # https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-string-explicit-encryption class TestStringExplicitEncryptionProse(AsyncEncryptionIntegrationTest): # The GA collections require server 9.0+, the preview collections require - # server pre-9.0. Test Setup encrypts with the "String" algorithm, which - # requires libmongocrypt 1.19.0+. + # server pre-9.0. Setup encrypts with the "String" algorithm on 9.0+ and the + # deprecated "textPreview" algorithm on earlier servers, since "String" was + # only introduced in libmongocrypt 1.19.0. @async_client_context.require_no_standalone @async_client_context.require_version_min(8, 2, -1) - @async_client_context.require_libmongocrypt_min(1, 19, 0) + @async_client_context.require_libmongocrypt_min(1, 18, 1) @async_client_context.require_pymongocrypt_min(1, 16, 0) async def asyncSetUp(self): await super().asyncSetUp() @@ -3417,6 +3426,13 @@ async def asyncSetUp(self): # The GA query types ("prefix", "suffix", "substring") require server # 9.0+, which in turn dropped the preview query types. self.is_ga = async_client_context.version.at_least(9, 0, -1) + # The "String" algorithm was added in libmongocrypt 1.19.0. Servers + # before 9.0 are tested against libmongocrypt 1.18.x, where the preview + # query types are only usable via the deprecated "textPreview" + # algorithm, so pick whichever the running combination supports. + self.algorithm = ( + Algorithm.STRING if _libmongocrypt_at_least(1, 19, 0) else Algorithm.TEXTPREVIEW + ) # Using QE CreateCollection() and Collection.Drop(), drop and create the # collections with majority write concern. @@ -3438,7 +3454,7 @@ async def asyncSetUp(self): encrypted_value = await self.client_encryption.encrypt( "foobarbaz", key_id=self.key1_id, - algorithm=Algorithm.STRING, + algorithm=self.algorithm, contention_factor=0, string_opts=StringOpts( case_sensitive=True, @@ -3459,7 +3475,7 @@ async def asyncSetUp(self): encrypted_value = await self.client_encryption.encrypt( "foobarbaz", key_id=self.key1_id, - algorithm=Algorithm.STRING, + algorithm=self.algorithm, contention_factor=0, string_opts=StringOpts( case_sensitive=True, @@ -3488,6 +3504,10 @@ def _require_query_type(self, query_type): raise unittest.SkipTest( f"queryType={query_type} requires libmongocrypt {'.'.join(map(str, required))}+" ) + if query_type in ("prefixPreview", "suffixPreview") and ( + _libmongocrypt_at_least(*_PREVIEW_REMOVED_IN) and not _libmongocrypt_at_least(1, 19, 1) + ): + raise unittest.SkipTest(f"queryType={query_type} was removed in libmongocrypt 1.19.0") def _params(self, kind): """Return the (query_type, collection) pair to run a case against. @@ -3519,7 +3539,7 @@ async def _encrypt(self, value, query_type=None, **string_opts): return await self.client_encryption.encrypt( value, key_id=self.key1_id, - algorithm=Algorithm.STRING, + algorithm=self.algorithm, query_type=query_type, contention_factor=0, string_opts=StringOpts(**string_opts), diff --git a/test/test_encryption.py b/test/test_encryption.py index 24338a5e53..1cbf388b24 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -3339,28 +3339,37 @@ def _libmongocrypt_at_least(*version): return Version.from_string(libmongocrypt_version()) >= Version(*version) -# The minimum libmongocrypt version required by each string query type, -# declared in one place so the test gates and the changelog agree. Support -# landed per query type rather than all at once: prefix and suffix in 1.19.0, -# substring in 1.20.0. +# The minimum libmongocrypt version required by each string query type, declared +# in one place so the test gates and the changelog agree. Support landed per +# query type rather than all at once (see the libmongocrypt changelog): +# 1.18.1 - fixes caseSensitive/diacriticSensitive handling for "textPreview". +# 1.19.0 - the "string" algorithm replaces "textPreview"; prefix and suffix go +# stable; prefixPreview and suffixPreview are removed. +# 1.19.1 - prefixPreview and suffixPreview are restored. +# 1.20.0 - substring goes stable. _STRING_QUERY_MIN_LIBMONGOCRYPT = { "prefix": (1, 19, 0), "suffix": (1, 19, 0), "substring": (1, 20, 0), - "prefixPreview": (1, 19, 1), - "suffixPreview": (1, 19, 1), - "substringPreview": (1, 19, 1), + "prefixPreview": (1, 18, 1), + "suffixPreview": (1, 18, 1), + "substringPreview": (1, 18, 1), } +# prefixPreview and suffixPreview were removed in 1.19.0 and restored in 1.19.1, +# so that one release is a hole rather than a floor. +_PREVIEW_REMOVED_IN = (1, 19, 0) + # https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-string-explicit-encryption class TestStringExplicitEncryptionProse(EncryptionIntegrationTest): # The GA collections require server 9.0+, the preview collections require - # server pre-9.0. Test Setup encrypts with the "String" algorithm, which - # requires libmongocrypt 1.19.0+. + # server pre-9.0. Setup encrypts with the "String" algorithm on 9.0+ and the + # deprecated "textPreview" algorithm on earlier servers, since "String" was + # only introduced in libmongocrypt 1.19.0. @client_context.require_no_standalone @client_context.require_version_min(8, 2, -1) - @client_context.require_libmongocrypt_min(1, 19, 0) + @client_context.require_libmongocrypt_min(1, 18, 1) @client_context.require_pymongocrypt_min(1, 16, 0) def setUp(self): super().setUp() @@ -3399,6 +3408,13 @@ def setUp(self): # The GA query types ("prefix", "suffix", "substring") require server # 9.0+, which in turn dropped the preview query types. self.is_ga = client_context.version.at_least(9, 0, -1) + # The "String" algorithm was added in libmongocrypt 1.19.0. Servers + # before 9.0 are tested against libmongocrypt 1.18.x, where the preview + # query types are only usable via the deprecated "textPreview" + # algorithm, so pick whichever the running combination supports. + self.algorithm = ( + Algorithm.STRING if _libmongocrypt_at_least(1, 19, 0) else Algorithm.TEXTPREVIEW + ) # Using QE CreateCollection() and Collection.Drop(), drop and create the # collections with majority write concern. @@ -3420,7 +3436,7 @@ def setUp(self): encrypted_value = self.client_encryption.encrypt( "foobarbaz", key_id=self.key1_id, - algorithm=Algorithm.STRING, + algorithm=self.algorithm, contention_factor=0, string_opts=StringOpts( case_sensitive=True, @@ -3441,7 +3457,7 @@ def setUp(self): encrypted_value = self.client_encryption.encrypt( "foobarbaz", key_id=self.key1_id, - algorithm=Algorithm.STRING, + algorithm=self.algorithm, contention_factor=0, string_opts=StringOpts( case_sensitive=True, @@ -3470,6 +3486,10 @@ def _require_query_type(self, query_type): raise unittest.SkipTest( f"queryType={query_type} requires libmongocrypt {'.'.join(map(str, required))}+" ) + if query_type in ("prefixPreview", "suffixPreview") and ( + _libmongocrypt_at_least(*_PREVIEW_REMOVED_IN) and not _libmongocrypt_at_least(1, 19, 1) + ): + raise unittest.SkipTest(f"queryType={query_type} was removed in libmongocrypt 1.19.0") def _params(self, kind): """Return the (query_type, collection) pair to run a case against. @@ -3501,7 +3521,7 @@ def _encrypt(self, value, query_type=None, **string_opts): return self.client_encryption.encrypt( value, key_id=self.key1_id, - algorithm=Algorithm.STRING, + algorithm=self.algorithm, query_type=query_type, contention_factor=0, string_opts=StringOpts(**string_opts), From a106c10e25a67f227d9433f0302bf72bfabd063d Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 19 Aug 2026 21:11:29 -0400 Subject: [PATCH 4/8] PYTHON-5909 Fetch libmongocrypt from signed releases and fix version gate Three fixes to get the string query prose tests actually running: - setup_tests.py gated the released-pymongocrypt pin on MONGODB_VERSION, which Evergreen never sets (it is only assigned inside the separate run_server.py process). Use VERSION, which is passed to "run tests". - MONGOCRYPT-838 moved the per-variant libmongocrypt release builds to a restricted bucket, leaving master/latest frozen at 1.18.0 and skipping the whole prose suite. Fetch the signed GitHub release assets instead, which are keyed by libc flavor rather than distro. - The prose setup encrypted the substring fixture unconditionally, so on libmongocrypt 1.19.x it errored before the substring cases could skip, taking the prefix and suffix cases with it. Only build the fixture where the query type exists. --- .evergreen/scripts/setup_tests.py | 102 +++++++++++++++++---------- test/asynchronous/test_encryption.py | 58 +++++++++------ test/test_encryption.py | 58 +++++++++------ 3 files changed, 140 insertions(+), 78 deletions(-) diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index 90f700bc6b..6a5d91f22c 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -52,6 +52,10 @@ # The python version used for perf tests. PERF_PYTHON_VERSION = "3.10.11" +# The libmongocrypt release used when LIBMONGOCRYPT_URL is not set. Must be at +# least 1.20.0 for the GA "substring" query type. +LIBMONGOCRYPT_VERSION = "1.20.2" + def is_set(var: str) -> bool: value = os.environ.get(var, "") @@ -72,43 +76,56 @@ def get_distro() -> Distro: return Distro(name=name, version_id=version_id, arch=arch) -def setup_libmongocrypt(): - target = "" +def get_libmongocrypt_target() -> str: + """Return the libmongocrypt release asset target for this platform. + + These are the names used by the signed release assets on GitHub, which are + keyed by libc flavor rather than by distro (MONGOCRYPT-838 moved the + per-variant release builds to a restricted bucket). + """ if PLATFORM == "windows": - # PYTHON-2808 Ensure this machine has the CA cert for google KMS. - if is_set("TEST_FLE_GCP_AUTO"): - run_command('powershell.exe "Invoke-WebRequest -URI https://oauth2.googleapis.com/"') - target = "windows-test" + return "windows-x86_64" + if PLATFORM == "darwin": + return "macos-universal" + + distro = get_distro() + arch = distro.arch + if arch in ("aarch64", "arm64"): + arch = "arm64" + elif arch in ("x86_64", "amd64"): + arch = "x86_64" + + # Alpine and other musl distros need the musl build. + if "Alpine" in distro.name: + if arch not in ("x86_64", "arm64"): + raise ValueError(f"No musl libmongocrypt build for architecture {distro.arch}!") + return f"linux-{arch}-musl_1_2-nocrypto" + + libc = { + "x86_64": "glibc_2_7", + "arm64": "glibc_2_17", + "ppc64le": "glibc_2_17", + "s390x": "glibc_2_7", + }.get(arch) + if libc is None: + raise ValueError(f"No libmongocrypt build for architecture {distro.arch}!") + return f"linux-{arch}-{libc}-nocrypto" - elif PLATFORM == "darwin": - target = "macos" - else: - distro = get_distro() - if distro.name.startswith("Debian"): - target = f"debian{distro.version_id}" - elif distro.name.startswith("Ubuntu"): - if distro.version_id == "20.04": - target = "debian11" - elif distro.version_id == "22.04": - target = "debian12" - elif distro.version_id == "24.04": - target = "debian13" - elif distro.name.startswith("Red Hat"): - if distro.version_id.startswith("7"): - target = "rhel-70-64-bit" - elif distro.version_id.startswith("8"): - if distro.arch == "aarch64": - target = "rhel-82-arm64" - else: - target = "rhel-80-64-bit" +def setup_libmongocrypt(): + if PLATFORM == "windows" and is_set("TEST_FLE_GCP_AUTO"): + # PYTHON-2808 Ensure this machine has the CA cert for google KMS. + run_command('powershell.exe "Invoke-WebRequest -URI https://oauth2.googleapis.com/"') - if not is_set("LIBMONGOCRYPT_URL"): - if not target: - raise ValueError("Cannot find libmongocrypt target for current platform!") - url = f"https://s3.amazonaws.com/mciuploads/libmongocrypt/{target}/master/latest/libmongocrypt.tar.gz" - else: + if is_set("LIBMONGOCRYPT_URL"): url = os.environ["LIBMONGOCRYPT_URL"] + else: + version = os.environ.get("LIBMONGOCRYPT_VERSION", LIBMONGOCRYPT_VERSION) + target = get_libmongocrypt_target() + url = ( + f"https://github.com/mongodb/libmongocrypt/releases/download/" + f"{version}/libmongocrypt-{target}-{version}.tar.gz" + ) shutil.rmtree(HERE / "libmongocrypt", ignore_errors=True) @@ -122,11 +139,22 @@ def setup_libmongocrypt(): LOGGER.info(f"Fetching {url}... done.") run_command("ls -la libmongocrypt") - run_command("ls -la libmongocrypt/nocrypto") if PLATFORM == "windows": # libmongocrypt's windows dll is not marked executable. - run_command("chmod +x libmongocrypt/nocrypto/bin/mongocrypt.dll") + run_command(f"chmod +x {get_libmongocrypt_base()}/bin/mongocrypt.dll") + + +def get_libmongocrypt_base() -> Path: + """Return the root of the extracted libmongocrypt archive. + + The signed release archives put ``lib/`` at the archive root, while the + older master builds nested everything under ``nocrypto/``. + """ + base = ROOT / "libmongocrypt" + if (base / "nocrypto").exists(): + return base / "nocrypto" + return base def load_config_from_file(path: str | Path) -> dict[str, str]: @@ -359,7 +387,9 @@ def handle_test_env() -> None: # need the "textPreview" algorithm, so pin to the released pymongocrypt # and use the libmongocrypt bundled in its wheel rather than the # unreleased master build. - use_released_pymongocrypt = os.environ.get("MONGODB_VERSION", "").startswith("8.") + # Evergreen exposes the server version as VERSION, not MONGODB_VERSION + # (which is only set inside the separate run_server.py process). + use_released_pymongocrypt = os.environ.get("VERSION", "").startswith("8.") if not use_released_pymongocrypt: # Check for libmongocrypt download. @@ -376,7 +406,7 @@ def handle_test_env() -> None: if not use_released_pymongocrypt: # Use the nocrypto build to avoid dependency issues with older windows/python versions. - BASE = ROOT / "libmongocrypt/nocrypto" + BASE = get_libmongocrypt_base() if PLATFORM == "linux": if (BASE / "lib/libmongocrypt.so").exists(): PYMONGOCRYPT_LIB = BASE / "lib/libmongocrypt.so" diff --git a/test/asynchronous/test_encryption.py b/test/asynchronous/test_encryption.py index 585eef8c83..768374aa5a 100644 --- a/test/asynchronous/test_encryption.py +++ b/test/asynchronous/test_encryption.py @@ -3433,14 +3433,29 @@ async def asyncSetUp(self): self.algorithm = ( Algorithm.STRING if _libmongocrypt_at_least(1, 19, 0) else Algorithm.TEXTPREVIEW ) + # The GA query types need the "String" algorithm, so skip before setup + # encrypts anything rather than erroring on an unsupported combination. + if self.is_ga and not _libmongocrypt_at_least(1, 19, 0): + raise unittest.SkipTest("server 9.0+ string queries require libmongocrypt 1.19.0+") + # Substring went stable in a later libmongocrypt than prefix and suffix, + # so its fixture is only built where the query type exists. Otherwise + # setup would fail before the substring cases could skip, taking the + # prefix and suffix cases down with it. + self.has_substring = _libmongocrypt_at_least( + *_STRING_QUERY_MIN_LIBMONGOCRYPT["substring" if self.is_ga else "substringPreview"] + ) # Using QE CreateCollection() and Collection.Drop(), drop and create the # collections with majority write concern. db = self.client_encrypted.db if self.is_ga: - collections = ["prefix-suffix", "prefix-suffix-ci-di", "substring", "substring-ci-di"] + collections = ["prefix-suffix", "prefix-suffix-ci-di"] + if self.has_substring: + collections += ["substring", "substring-ci-di"] else: - collections = ["prefix-suffix-preview", "substring-preview"] + collections = ["prefix-suffix-preview"] + if self.has_substring: + collections += ["substring-preview"] for name in collections: await db.drop_collection(name) await self.client_encryption.create_encrypted_collection( @@ -3471,25 +3486,26 @@ async def asyncSetUp(self): {"_id": 0, "encryptedText": encrypted_value}, ) - # Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts. - encrypted_value = await self.client_encryption.encrypt( - "foobarbaz", - key_id=self.key1_id, - algorithm=self.algorithm, - contention_factor=0, - string_opts=StringOpts( - case_sensitive=True, - diacritic_sensitive=True, - substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), - ), - ) - # Use explicitEncryptedClient to insert the following document into - # db.substring (if created) and db.substring-preview (if created) with - # majority write concern. - await self._insert( - "substring" if self.is_ga else "substring-preview", - {"_id": 0, "encryptedText": encrypted_value}, - ) + if self.has_substring: + # Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts. + encrypted_value = await self.client_encryption.encrypt( + "foobarbaz", + key_id=self.key1_id, + algorithm=self.algorithm, + contention_factor=0, + string_opts=StringOpts( + case_sensitive=True, + diacritic_sensitive=True, + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), + ), + ) + # Use explicitEncryptedClient to insert the following document into + # db.substring (if created) and db.substring-preview (if created) with + # majority write concern. + await self._insert( + "substring" if self.is_ga else "substring-preview", + {"_id": 0, "encryptedText": encrypted_value}, + ) async def _insert(self, collection, document, client=None): """Insert a document with majority write concern.""" diff --git a/test/test_encryption.py b/test/test_encryption.py index 1cbf388b24..00b74d90a6 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -3415,14 +3415,29 @@ def setUp(self): self.algorithm = ( Algorithm.STRING if _libmongocrypt_at_least(1, 19, 0) else Algorithm.TEXTPREVIEW ) + # The GA query types need the "String" algorithm, so skip before setup + # encrypts anything rather than erroring on an unsupported combination. + if self.is_ga and not _libmongocrypt_at_least(1, 19, 0): + raise unittest.SkipTest("server 9.0+ string queries require libmongocrypt 1.19.0+") + # Substring went stable in a later libmongocrypt than prefix and suffix, + # so its fixture is only built where the query type exists. Otherwise + # setup would fail before the substring cases could skip, taking the + # prefix and suffix cases down with it. + self.has_substring = _libmongocrypt_at_least( + *_STRING_QUERY_MIN_LIBMONGOCRYPT["substring" if self.is_ga else "substringPreview"] + ) # Using QE CreateCollection() and Collection.Drop(), drop and create the # collections with majority write concern. db = self.client_encrypted.db if self.is_ga: - collections = ["prefix-suffix", "prefix-suffix-ci-di", "substring", "substring-ci-di"] + collections = ["prefix-suffix", "prefix-suffix-ci-di"] + if self.has_substring: + collections += ["substring", "substring-ci-di"] else: - collections = ["prefix-suffix-preview", "substring-preview"] + collections = ["prefix-suffix-preview"] + if self.has_substring: + collections += ["substring-preview"] for name in collections: db.drop_collection(name) self.client_encryption.create_encrypted_collection( @@ -3453,25 +3468,26 @@ def setUp(self): {"_id": 0, "encryptedText": encrypted_value}, ) - # Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts. - encrypted_value = self.client_encryption.encrypt( - "foobarbaz", - key_id=self.key1_id, - algorithm=self.algorithm, - contention_factor=0, - string_opts=StringOpts( - case_sensitive=True, - diacritic_sensitive=True, - substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), - ), - ) - # Use explicitEncryptedClient to insert the following document into - # db.substring (if created) and db.substring-preview (if created) with - # majority write concern. - self._insert( - "substring" if self.is_ga else "substring-preview", - {"_id": 0, "encryptedText": encrypted_value}, - ) + if self.has_substring: + # Use clientEncryption to encrypt the string "foobarbaz" with the following EncryptOpts. + encrypted_value = self.client_encryption.encrypt( + "foobarbaz", + key_id=self.key1_id, + algorithm=self.algorithm, + contention_factor=0, + string_opts=StringOpts( + case_sensitive=True, + diacritic_sensitive=True, + substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), + ), + ) + # Use explicitEncryptedClient to insert the following document into + # db.substring (if created) and db.substring-preview (if created) with + # majority write concern. + self._insert( + "substring" if self.is_ga else "substring-preview", + {"_id": 0, "encryptedText": encrypted_value}, + ) def _insert(self, collection, document, client=None): """Insert a document with majority write concern.""" From d6865ff770260b37ae76eaf72b78b8ae4d79e966 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 19 Aug 2026 21:27:44 -0400 Subject: [PATCH 5/8] PYTHON-5909 Add an 8.2 task for the preview string query types The preview query types need a server that is at least 8.2 and older than 9.0, but ALL_VERSIONS jumps straight from 8.0 to 9.0, so the prose cases skipped on the server version gate no matter which libmongocrypt was installed. Add a dedicated task for the encryption variants. --- .evergreen/generated_configs/tasks.yml | 23 +++++++++++++++ .evergreen/generated_configs/variants.yml | 6 ++++ .evergreen/scripts/generate_config.py | 36 +++++++++++++++++++++-- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/.evergreen/generated_configs/tasks.yml b/.evergreen/generated_configs/tasks.yml index 88f1fe0051..fec81ab172 100644 --- a/.evergreen/generated_configs/tasks.yml +++ b/.evergreen/generated_configs/tasks.yml @@ -4591,6 +4591,29 @@ tasks: - async - free-threaded + # String query preview tests + - name: test-string-query-preview-v8.2-python3.14-noauth-ssl-replica-set + commands: + - func: run server + vars: + AUTH: noauth + SSL: ssl + TOPOLOGY: replica_set + VERSION: "8.2" + - func: run tests + vars: + AUTH: noauth + SSL: ssl + TOPOLOGY: replica_set + VERSION: "8.2" + TOOLCHAIN_VERSION: "3.14" + tags: + - test-string-query-preview + - server-8.2 + - python-3.14 + - replica_set-noauth-ssl + - noauth + # Test non standard tests - name: test-non-standard-v4.2-python3.11-noauth-ssl-replica-set commands: diff --git a/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index dfa03da69f..989db0c400 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -184,6 +184,7 @@ buildvariants: - name: encryption-rhel8 tasks: - name: .test-non-standard + - name: .test-string-query-preview display_name: Encryption RHEL8 run_on: - rhel87-small @@ -195,6 +196,7 @@ buildvariants: tasks: - name: .test-non-standard !.pypy !.cov - name: .test-non-standard-no-cov !.pypy + - name: .test-string-query-preview display_name: Encryption macOS run_on: - macos-14 @@ -206,6 +208,7 @@ buildvariants: tasks: - name: .test-non-standard !.pypy !.cov - name: .test-non-standard-no-cov !.pypy + - name: .test-string-query-preview display_name: Encryption Win64 run_on: - windows-2022-latest-small @@ -216,6 +219,7 @@ buildvariants: - name: encryption-crypt_shared-rhel8 tasks: - name: .test-non-standard + - name: .test-string-query-preview display_name: Encryption crypt_shared RHEL8 run_on: - rhel87-small @@ -228,6 +232,7 @@ buildvariants: tasks: - name: .test-non-standard !.pypy !.cov - name: .test-non-standard-no-cov !.pypy + - name: .test-string-query-preview display_name: Encryption crypt_shared macOS run_on: - macos-14 @@ -240,6 +245,7 @@ buildvariants: tasks: - name: .test-non-standard !.pypy !.cov - name: .test-non-standard-no-cov !.pypy + - name: .test-string-query-preview display_name: Encryption crypt_shared Win64 run_on: - windows-2022-latest-small diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index 3318625a25..837492e122 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -130,13 +130,17 @@ def get_encryption_expansions(encryption): ): expansions = get_encryption_expansions(encryption) display_name = get_variant_name(encryption, host, **expansions) - tasks = [".test-non-standard"] + tasks = [".test-non-standard", ".test-string-query-preview"] if host != "rhel8": # Exclude PyPy (not tested with encryption on macOS/win64) and coverage tasks # (encryption suites exceed the 60-min timeout with coverage overhead on macOS/win64). # Also include the non-coverage companion tasks (test-non-standard-no-cov) which # carry the "latest" server tasks without COVERAGE=1. - tasks = [".test-non-standard !.pypy !.cov", ".test-non-standard-no-cov !.pypy"] + tasks = [ + ".test-non-standard !.pypy !.cov", + ".test-non-standard-no-cov !.pypy", + ".test-string-query-preview", + ] variant = create_variant( tasks, display_name, @@ -719,6 +723,34 @@ def create_test_non_standard_tasks(): return tasks +def create_string_query_preview_tasks(): + """Tasks for the preview Queryable Encryption string query types. + + The preview query types need a server that is at least 8.2 and older than + 9.0, and ALL_VERSIONS jumps straight from 8.0 to 9.0, so they have nowhere + to run without a dedicated task. setup_tests.py pins the released + pymongocrypt for 8.x, which bundles a libmongocrypt still carrying the + preview types. + """ + python = CPYTHONS[-1] + topology = "replica_set" + auth, ssl = get_standard_auth_ssl(topology) + expansions = dict(AUTH=auth, SSL=ssl, TOPOLOGY=topology, VERSION="8.2") + tags = [ + "test-string-query-preview", + "server-8.2", + f"python-{python}", + f"{topology}-{auth}-{ssl}", + auth, + ] + name = get_task_name("test-string-query-preview", python=python, **expansions) + server_func = FunctionCall(func="run server", vars=expansions) + test_vars = expansions.copy() + test_vars["TOOLCHAIN_VERSION"] = python + test_func = FunctionCall(func="run tests", vars=test_vars) + return [EvgTask(name=name, tags=tags, commands=[server_func, test_func])] + + def create_test_standard_auth_tasks(): """We only use auth on sharded clusters""" tasks = [] From d7922b1554e916d73b2b5668e1808a6f5209b147 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 20 Aug 2026 10:14:56 -0400 Subject: [PATCH 6/8] PYTHON-5909 Resolve the pymongocrypt string_opts kwarg from the installed signature pymongocrypt renamed text_opts to string_opts in 1.19, so the hardcoded text_opts kwarg raised TypeError on the master builds the GA query types require, failing all 11 prose cases on every encryption variant. The 8.x preview tasks still pin pymongocrypt < 1.19, which only accepts text_opts, so resolve the name from the installed signature. A version check would not work: master reports 1.19.0.dev0, which sorts below 1.19.0. Also fix the Windows dll chmod, which shelled out to a POSIX chmod that cannot resolve the drive-lettered absolute path get_libmongocrypt_base() returns, aborting setup before any test ran on the win64 variants. --- .evergreen/scripts/setup_tests.py | 7 +++++-- pymongo/asynchronous/encryption.py | 16 +++++++++++++--- pymongo/synchronous/encryption.py | 16 +++++++++++++--- test/asynchronous/test_encryption.py | 17 +++++++++++++++++ test/test_encryption.py | 17 +++++++++++++++++ 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index 6a5d91f22c..2ba44f7698 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -141,8 +141,11 @@ def setup_libmongocrypt(): run_command("ls -la libmongocrypt") if PLATFORM == "windows": - # libmongocrypt's windows dll is not marked executable. - run_command(f"chmod +x {get_libmongocrypt_base()}/bin/mongocrypt.dll") + # libmongocrypt's windows dll is not marked executable. Use Path.chmod + # rather than shelling out: the bundled POSIX chmod cannot resolve the + # drive-lettered absolute path that get_libmongocrypt_base() returns. + dll = get_libmongocrypt_base() / "bin/mongocrypt.dll" + dll.chmod(dll.stat().st_mode | stat.S_IEXEC) def get_libmongocrypt_base() -> Path: diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index 49b32edd93..53a3ea497b 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -19,6 +19,7 @@ import asyncio import contextlib import enum +import inspect import socket import time as time # noqa: PLC0414 # needed in sync version import uuid @@ -47,9 +48,20 @@ from pymongocrypt.mongocrypt import MongoCryptOptions # type:ignore[import] _HAVE_PYMONGOCRYPT = True + # pymongocrypt renamed the text_opts parameter to string_opts in 1.19. The + # preview query types still run against pymongocrypt < 1.19, so resolve the + # name from the installed signature rather than from a version comparison: + # the rename landed on master before any release carried it, so a version + # check would misclassify the master builds the GA query types require. + _STRING_OPTS_KWARG = ( + "string_opts" + if "string_opts" in inspect.signature(AsyncExplicitEncrypter.encrypt).parameters + else "text_opts" + ) except ImportError: _HAVE_PYMONGOCRYPT = False AsyncMongoCryptCallback = object + _STRING_OPTS_KWARG = "string_opts" from bson import _dict_to_bson, decode, encode from bson.binary import STANDARD, UUID_SUBTYPE, Binary @@ -1016,9 +1028,7 @@ async def _encrypt_helper( contention_factor=contention_factor, range_opts=range_opts_bytes, is_expression=is_expression, - # pymongocrypt still names this parameter text_opts. - # For compatibility with pymongocrypt < 1.16: - **{"text_opts": string_opts_bytes} if string_opts_bytes else {}, + **({_STRING_OPTS_KWARG: string_opts_bytes} if string_opts_bytes else {}), ) return decode(encrypted_doc)["v"] diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index 5516237146..25220ca67b 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -18,6 +18,7 @@ import contextlib import enum +import inspect import socket import time as time # noqa: PLC0414 # needed in sync version import uuid @@ -46,9 +47,20 @@ ) _HAVE_PYMONGOCRYPT = True + # pymongocrypt renamed the text_opts parameter to string_opts in 1.19. The + # preview query types still run against pymongocrypt < 1.19, so resolve the + # name from the installed signature rather than from a version comparison: + # the rename landed on master before any release carried it, so a version + # check would misclassify the master builds the GA query types require. + _STRING_OPTS_KWARG = ( + "string_opts" + if "string_opts" in inspect.signature(ExplicitEncrypter.encrypt).parameters + else "text_opts" + ) except ImportError: _HAVE_PYMONGOCRYPT = False MongoCryptCallback = object + _STRING_OPTS_KWARG = "string_opts" from bson import _dict_to_bson, decode, encode from bson.binary import STANDARD, UUID_SUBTYPE, Binary @@ -1009,9 +1021,7 @@ def _encrypt_helper( contention_factor=contention_factor, range_opts=range_opts_bytes, is_expression=is_expression, - # pymongocrypt still names this parameter text_opts. - # For compatibility with pymongocrypt < 1.16: - **{"text_opts": string_opts_bytes} if string_opts_bytes else {}, + **({_STRING_OPTS_KWARG: string_opts_bytes} if string_opts_bytes else {}), ) return decode(encrypted_doc)["v"] diff --git a/test/asynchronous/test_encryption.py b/test/asynchronous/test_encryption.py index 768374aa5a..d5e0f498e2 100644 --- a/test/asynchronous/test_encryption.py +++ b/test/asynchronous/test_encryption.py @@ -19,6 +19,7 @@ import base64 import copy import http.client +import inspect import json import os import pathlib @@ -266,6 +267,16 @@ def test_resolve_string_opts_rejects_both(self): with self.assertRaises(ConfigurationError): encryption._resolve_string_opts(string_opts, string_opts) + @unittest.skipUnless(_HAVE_PYMONGOCRYPT, "pymongocrypt is not installed") + def test_string_opts_kwarg_matches_binding(self): + # pymongocrypt renamed text_opts to string_opts in 1.19, and both + # spellings are still in play: the GA query types need a master build + # and the preview query types run against pymongocrypt < 1.19. Assert + # the resolved name against the installed binding here so a mismatch + # fails without a server, rather than only in the prose suite. + params = inspect.signature(encryption.AsyncExplicitEncrypter.encrypt).parameters + self.assertIn(encryption._STRING_OPTS_KWARG, params) + class AsyncEncryptionIntegrationTest(AsyncIntegrationTest): """Base class for encryption integration tests.""" @@ -3378,6 +3389,12 @@ def _libmongocrypt_at_least(*version): # so that one release is a hole rather than a floor. _PREVIEW_REMOVED_IN = (1, 19, 0) +# No pymongocrypt release ships libmongocrypt 1.19.0+, so the GA query types can +# only run against a master build; setup_tests.py pins the released binding for +# the 8.x preview tasks alone. Do not collapse that gate: the released binding +# still spells the parameter text_opts, and dropping back to it would silently +# stop exercising the GA path. + # https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-string-explicit-encryption class TestStringExplicitEncryptionProse(AsyncEncryptionIntegrationTest): diff --git a/test/test_encryption.py b/test/test_encryption.py index 00b74d90a6..1b5c118732 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -19,6 +19,7 @@ import base64 import copy import http.client +import inspect import json import os import pathlib @@ -266,6 +267,16 @@ def test_resolve_string_opts_rejects_both(self): with self.assertRaises(ConfigurationError): encryption._resolve_string_opts(string_opts, string_opts) + @unittest.skipUnless(_HAVE_PYMONGOCRYPT, "pymongocrypt is not installed") + def test_string_opts_kwarg_matches_binding(self): + # pymongocrypt renamed text_opts to string_opts in 1.19, and both + # spellings are still in play: the GA query types need a master build + # and the preview query types run against pymongocrypt < 1.19. Assert + # the resolved name against the installed binding here so a mismatch + # fails without a server, rather than only in the prose suite. + params = inspect.signature(encryption.ExplicitEncrypter.encrypt).parameters + self.assertIn(encryption._STRING_OPTS_KWARG, params) + class EncryptionIntegrationTest(IntegrationTest): """Base class for encryption integration tests.""" @@ -3360,6 +3371,12 @@ def _libmongocrypt_at_least(*version): # so that one release is a hole rather than a floor. _PREVIEW_REMOVED_IN = (1, 19, 0) +# No pymongocrypt release ships libmongocrypt 1.19.0+, so the GA query types can +# only run against a master build; setup_tests.py pins the released binding for +# the 8.x preview tasks alone. Do not collapse that gate: the released binding +# still spells the parameter text_opts, and dropping back to it would silently +# stop exercising the GA path. + # https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-string-explicit-encryption class TestStringExplicitEncryptionProse(EncryptionIntegrationTest): From c163fa5bd046c21708ef283c46a03b99470f83c1 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 20 Aug 2026 16:11:52 -0400 Subject: [PATCH 7/8] PYTHON-5909 Address review: error on the unsupported string opts name - Rename use_released_pymongocrypt to use_pymongocrypt_text_preview, which says what the pin is for rather than which pymongocrypt is newest. - Resolve the pymongocrypt kwarg name lazily in a cached helper and import inspect inside it, keeping both off the import path. - pymongocrypt accepts only one of text_opts/string_opts per release, so raise ConfigurationError when the name the installed binding does not support is passed, instead of silently aliasing text_opts with a DeprecationWarning. The prose tests now pass StringOpts under the resolved name. - Name the pymongocrypt versions in the TextOpts docs and the changelog. --- .evergreen/scripts/setup_tests.py | 8 ++-- doc/changelog.rst | 6 ++- pymongo/asynchronous/encryption.py | 69 +++++++++++++++++----------- pymongo/encryption_options.py | 6 +++ pymongo/synchronous/encryption.py | 69 +++++++++++++++++----------- test/asynchronous/test_encryption.py | 52 +++++++++++++-------- test/test_encryption.py | 52 +++++++++++++-------- 7 files changed, 169 insertions(+), 93 deletions(-) diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index 2ba44f7698..c28f724113 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -392,22 +392,22 @@ def handle_test_env() -> None: # unreleased master build. # Evergreen exposes the server version as VERSION, not MONGODB_VERSION # (which is only set inside the separate run_server.py process). - use_released_pymongocrypt = os.environ.get("VERSION", "").startswith("8.") + use_pymongocrypt_text_preview = os.environ.get("VERSION", "").startswith("8.") - if not use_released_pymongocrypt: + if not use_pymongocrypt_text_preview: # Check for libmongocrypt download. if not (ROOT / "libmongocrypt").exists(): setup_libmongocrypt() if not opts.test_min_deps: - if use_released_pymongocrypt: + if use_pymongocrypt_text_preview: UV_ARGS.append("--with pymongocrypt<1.19") else: UV_ARGS.append( "--with pymongocrypt@git+https://github.com/mongodb/libmongocrypt@master#subdirectory=bindings/python" ) - if not use_released_pymongocrypt: + if not use_pymongocrypt_text_preview: # Use the nocrypto build to avoid dependency issues with older windows/python versions. BASE = get_libmongocrypt_base() if PLATFORM == "linux": diff --git a/doc/changelog.rst b/doc/changelog.rst index 984d035d20..104270236a 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -55,7 +55,11 @@ PyMongo 4.18 brings a number of changes including: - Added the ``string_opts`` parameter to :meth:`~pymongo.encryption.ClientEncryption.encrypt` and :meth:`~pymongo.asynchronous.encryption.AsyncClientEncryption.encrypt`, - deprecating ``text_opts``. + deprecating ``text_opts``. pymongocrypt renamed this parameter in 1.19 and + accepts only one of the two names per release, so passing ``text_opts`` + with pymongocrypt 1.19 or later, ``string_opts`` with pymongocrypt 1.18 or + earlier, or both names at once, raises + :exc:`~pymongo.errors.ConfigurationError`. - Aggregation helpers now raise :exc:`~pymongo.errors.ConfigurationError` when passed an ``aggregate`` or ``pipeline`` keyword argument. Previously these keys silently replaced the target namespace and pipeline of the generated diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index 53a3ea497b..410b4664b1 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -19,11 +19,10 @@ import asyncio import contextlib import enum -import inspect +import functools import socket import time as time # noqa: PLC0414 # needed in sync version import uuid -import warnings import weakref from collections.abc import AsyncGenerator, Iterator, Mapping, MutableMapping, Sequence from copy import deepcopy @@ -48,20 +47,9 @@ from pymongocrypt.mongocrypt import MongoCryptOptions # type:ignore[import] _HAVE_PYMONGOCRYPT = True - # pymongocrypt renamed the text_opts parameter to string_opts in 1.19. The - # preview query types still run against pymongocrypt < 1.19, so resolve the - # name from the installed signature rather than from a version comparison: - # the rename landed on master before any release carried it, so a version - # check would misclassify the master builds the GA query types require. - _STRING_OPTS_KWARG = ( - "string_opts" - if "string_opts" in inspect.signature(AsyncExplicitEncrypter.encrypt).parameters - else "text_opts" - ) except ImportError: _HAVE_PYMONGOCRYPT = False AsyncMongoCryptCallback = object - _STRING_OPTS_KWARG = "string_opts" from bson import _dict_to_bson, decode, encode from bson.binary import STANDARD, UUID_SUBTYPE, Binary @@ -637,20 +625,45 @@ class QueryType(str, enum.Enum): """ +@functools.lru_cache(maxsize=1) +def _string_opts_kwarg() -> str: + """The name the installed pymongocrypt gives the string index options. + + pymongocrypt renamed ``text_opts`` to ``string_opts`` in 1.19 and accepts + only one name per release. The rename landed on master before any release + carried it, so resolve the name from the installed signature rather than + from a version comparison. Resolved lazily and cached because importing + :mod:`inspect` is too expensive to do on the import path. + """ + import inspect + + params = inspect.signature(AsyncExplicitEncrypter.encrypt).parameters + return "string_opts" if "string_opts" in params else "text_opts" + + def _resolve_string_opts( string_opts: Optional[StringOpts], text_opts: Optional[StringOpts] ) -> Optional[StringOpts]: - """Resolve the deprecated text_opts alias for string_opts.""" - if text_opts is None: - return string_opts - if string_opts is not None: + """Resolve string_opts and its former name, text_opts. + + pymongocrypt accepts only one of the two names per release, so passing the + name the installed pymongocrypt does not support is an error. + """ + if string_opts is not None and text_opts is not None: raise ConfigurationError("Cannot set both string_opts and text_opts") - warnings.warn( - "The text_opts parameter is deprecated. Use string_opts instead.", - DeprecationWarning, - stacklevel=3, - ) - return text_opts + if string_opts is None and text_opts is None: + return None + if text_opts is not None and _string_opts_kwarg() == "string_opts": + raise ConfigurationError( + "text_opts is not supported by the installed pymongocrypt " + "(1.19 or later). Use string_opts instead." + ) + if string_opts is not None and _string_opts_kwarg() == "text_opts": + raise ConfigurationError( + "string_opts requires pymongocrypt 1.19 or later. Use text_opts " + "with the installed pymongocrypt, or upgrade pymongocrypt." + ) + return string_opts if string_opts is not None else text_opts def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions: @@ -1028,7 +1041,7 @@ async def _encrypt_helper( contention_factor=contention_factor, range_opts=range_opts_bytes, is_expression=is_expression, - **({_STRING_OPTS_KWARG: string_opts_bytes} if string_opts_bytes else {}), + **({_string_opts_kwarg(): string_opts_bytes} if string_opts_bytes else {}), ) return decode(encrypted_doc)["v"] @@ -1065,12 +1078,16 @@ async def encrypt( :class:`RangeOpts` for some valid options. :param string_opts: Index options for `prefix`, `suffix`, and `substring` queries. See :class:`StringOpts` for some valid options. - :param text_opts: **DEPRECATED** - Alias for `string_opts`. + :param text_opts: **DEPRECATED** - The former name of `string_opts`, + accepted only when pymongocrypt is older than 1.19. Passing it to a + newer pymongocrypt, or passing both names, raises + :exc:`~pymongo.errors.ConfigurationError`. :return: The encrypted value, a :class:`~bson.binary.Binary` with subtype 6. .. versionchanged:: 4.18 - Added the `string_opts` parameter and deprecated `text_opts`. + Added the `string_opts` parameter, replacing the deprecated + `text_opts`. .. versionchanged:: 4.9 Added the `text_opts` parameter. diff --git a/pymongo/encryption_options.py b/pymongo/encryption_options.py index 065e7f1590..734039d062 100644 --- a/pymongo/encryption_options.py +++ b/pymongo/encryption_options.py @@ -398,6 +398,12 @@ class TextOpts(StringOpts): """**DEPRECATED** Options to configure encrypted queries using the text algorithm. .. note:: ``TextOpts`` is deprecated. Use :class:`StringOpts` instead. + ``TextOpts`` corresponds to the ``text_opts`` parameter of + :meth:`~pymongo.encryption.ClientEncryption.encrypt`, which pymongocrypt + accepted through 1.18 and renamed to ``string_opts`` in 1.19. Passing + ``TextOpts`` as ``text_opts`` therefore only works with pymongocrypt + 1.18 or older; with pymongocrypt 1.19 or later, pass + :class:`StringOpts` as ``string_opts``. .. versionadded:: 4.15 diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index 25220ca67b..b9b008c0da 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -18,11 +18,10 @@ import contextlib import enum -import inspect +import functools import socket import time as time # noqa: PLC0414 # needed in sync version import uuid -import warnings import weakref from collections.abc import Generator, Iterator, Mapping, MutableMapping, Sequence from copy import deepcopy @@ -47,20 +46,9 @@ ) _HAVE_PYMONGOCRYPT = True - # pymongocrypt renamed the text_opts parameter to string_opts in 1.19. The - # preview query types still run against pymongocrypt < 1.19, so resolve the - # name from the installed signature rather than from a version comparison: - # the rename landed on master before any release carried it, so a version - # check would misclassify the master builds the GA query types require. - _STRING_OPTS_KWARG = ( - "string_opts" - if "string_opts" in inspect.signature(ExplicitEncrypter.encrypt).parameters - else "text_opts" - ) except ImportError: _HAVE_PYMONGOCRYPT = False MongoCryptCallback = object - _STRING_OPTS_KWARG = "string_opts" from bson import _dict_to_bson, decode, encode from bson.binary import STANDARD, UUID_SUBTYPE, Binary @@ -634,20 +622,45 @@ class QueryType(str, enum.Enum): """ +@functools.lru_cache(maxsize=1) +def _string_opts_kwarg() -> str: + """The name the installed pymongocrypt gives the string index options. + + pymongocrypt renamed ``text_opts`` to ``string_opts`` in 1.19 and accepts + only one name per release. The rename landed on master before any release + carried it, so resolve the name from the installed signature rather than + from a version comparison. Resolved lazily and cached because importing + :mod:`inspect` is too expensive to do on the import path. + """ + import inspect + + params = inspect.signature(ExplicitEncrypter.encrypt).parameters + return "string_opts" if "string_opts" in params else "text_opts" + + def _resolve_string_opts( string_opts: Optional[StringOpts], text_opts: Optional[StringOpts] ) -> Optional[StringOpts]: - """Resolve the deprecated text_opts alias for string_opts.""" - if text_opts is None: - return string_opts - if string_opts is not None: + """Resolve string_opts and its former name, text_opts. + + pymongocrypt accepts only one of the two names per release, so passing the + name the installed pymongocrypt does not support is an error. + """ + if string_opts is not None and text_opts is not None: raise ConfigurationError("Cannot set both string_opts and text_opts") - warnings.warn( - "The text_opts parameter is deprecated. Use string_opts instead.", - DeprecationWarning, - stacklevel=3, - ) - return text_opts + if string_opts is None and text_opts is None: + return None + if text_opts is not None and _string_opts_kwarg() == "string_opts": + raise ConfigurationError( + "text_opts is not supported by the installed pymongocrypt " + "(1.19 or later). Use string_opts instead." + ) + if string_opts is not None and _string_opts_kwarg() == "text_opts": + raise ConfigurationError( + "string_opts requires pymongocrypt 1.19 or later. Use text_opts " + "with the installed pymongocrypt, or upgrade pymongocrypt." + ) + return string_opts if string_opts is not None else text_opts def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions: @@ -1021,7 +1034,7 @@ def _encrypt_helper( contention_factor=contention_factor, range_opts=range_opts_bytes, is_expression=is_expression, - **({_STRING_OPTS_KWARG: string_opts_bytes} if string_opts_bytes else {}), + **({_string_opts_kwarg(): string_opts_bytes} if string_opts_bytes else {}), ) return decode(encrypted_doc)["v"] @@ -1058,12 +1071,16 @@ def encrypt( :class:`RangeOpts` for some valid options. :param string_opts: Index options for `prefix`, `suffix`, and `substring` queries. See :class:`StringOpts` for some valid options. - :param text_opts: **DEPRECATED** - Alias for `string_opts`. + :param text_opts: **DEPRECATED** - The former name of `string_opts`, + accepted only when pymongocrypt is older than 1.19. Passing it to a + newer pymongocrypt, or passing both names, raises + :exc:`~pymongo.errors.ConfigurationError`. :return: The encrypted value, a :class:`~bson.binary.Binary` with subtype 6. .. versionchanged:: 4.18 - Added the `string_opts` parameter and deprecated `text_opts`. + Added the `string_opts` parameter, replacing the deprecated + `text_opts`. .. versionchanged:: 4.9 Added the `text_opts` parameter. diff --git a/test/asynchronous/test_encryption.py b/test/asynchronous/test_encryption.py index d5e0f498e2..6a69fb1c6c 100644 --- a/test/asynchronous/test_encryption.py +++ b/test/asynchronous/test_encryption.py @@ -237,6 +237,16 @@ async def test_kwargs(self): self.assertEqual(get_client_opts(client).auto_encryption_opts, opts) +def _string_opts_kwargs(**kwargs: Any) -> dict[str, StringOpts]: + """Pass StringOpts under the name the installed pymongocrypt accepts. + + pymongocrypt renamed ``text_opts`` to ``string_opts`` in 1.19 and takes only + one of the two, and the preview query types still run against pymongocrypt + before 1.19, so both spellings are in play across the matrix. + """ + return {encryption._string_opts_kwarg(): StringOpts(**kwargs)} + + class TestStringOptsDeprecation(AsyncPyMongoTestCase): def test_text_opts_is_still_re_exported(self): # TextOpts is deprecated, not removed, so it must stay importable from @@ -252,30 +262,36 @@ def test_text_opts_is_deprecated(self): opts.document, ) - def test_resolve_string_opts(self): - string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + def test_resolve_string_opts_no_opts(self): self.assertIsNone(encryption._resolve_string_opts(None, None)) - self.assertIs(encryption._resolve_string_opts(string_opts, None), string_opts) - - def test_resolve_string_opts_text_opts_is_deprecated(self): - string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) - with self.assertWarns(DeprecationWarning): - self.assertIs(encryption._resolve_string_opts(None, string_opts), string_opts) def test_resolve_string_opts_rejects_both(self): string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) with self.assertRaises(ConfigurationError): encryption._resolve_string_opts(string_opts, string_opts) + @unittest.skipUnless(_HAVE_PYMONGOCRYPT, "pymongocrypt is not installed") + def test_resolve_string_opts_follows_pymongocrypt(self): + # pymongocrypt accepts only one of the two names per release: text_opts + # through 1.18 and string_opts from 1.19. Passing the name the + # installed binding does not support is an error rather than a silent + # alias, so assert against the resolved name. + string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + if encryption._string_opts_kwarg() == "string_opts": + supported, unsupported = (string_opts, None), (None, string_opts) + else: + supported, unsupported = (None, string_opts), (string_opts, None) + self.assertIs(encryption._resolve_string_opts(*supported), string_opts) + with self.assertRaises(ConfigurationError): + encryption._resolve_string_opts(*unsupported) + @unittest.skipUnless(_HAVE_PYMONGOCRYPT, "pymongocrypt is not installed") def test_string_opts_kwarg_matches_binding(self): - # pymongocrypt renamed text_opts to string_opts in 1.19, and both - # spellings are still in play: the GA query types need a master build - # and the preview query types run against pymongocrypt < 1.19. Assert - # the resolved name against the installed binding here so a mismatch - # fails without a server, rather than only in the prose suite. + # The resolved name is passed straight through to pymongocrypt, so + # assert it against the installed binding here: a mismatch then fails + # without a server, rather than only in the prose suite. params = inspect.signature(encryption.AsyncExplicitEncrypter.encrypt).parameters - self.assertIn(encryption._STRING_OPTS_KWARG, params) + self.assertIn(encryption._string_opts_kwarg(), params) class AsyncEncryptionIntegrationTest(AsyncIntegrationTest): @@ -3488,7 +3504,7 @@ async def asyncSetUp(self): key_id=self.key1_id, algorithm=self.algorithm, contention_factor=0, - string_opts=StringOpts( + **_string_opts_kwargs( case_sensitive=True, diacritic_sensitive=True, prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), @@ -3510,7 +3526,7 @@ async def asyncSetUp(self): key_id=self.key1_id, algorithm=self.algorithm, contention_factor=0, - string_opts=StringOpts( + **_string_opts_kwargs( case_sensitive=True, diacritic_sensitive=True, substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), @@ -3575,7 +3591,7 @@ async def _encrypt(self, value, query_type=None, **string_opts): algorithm=self.algorithm, query_type=query_type, contention_factor=0, - string_opts=StringOpts(**string_opts), + **_string_opts_kwargs(**string_opts), ) async def _find(self, collection, filter): @@ -3719,7 +3735,7 @@ async def test_07_contentionFactor_is_required(self): key_id=self.key1_id, algorithm=Algorithm.STRING, query_type=QueryType.PREFIX, - string_opts=StringOpts( + **_string_opts_kwargs( case_sensitive=True, diacritic_sensitive=True, prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), diff --git a/test/test_encryption.py b/test/test_encryption.py index 1b5c118732..10c3627780 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -237,6 +237,16 @@ def test_kwargs(self): self.assertEqual(get_client_opts(client).auto_encryption_opts, opts) +def _string_opts_kwargs(**kwargs: Any) -> dict[str, StringOpts]: + """Pass StringOpts under the name the installed pymongocrypt accepts. + + pymongocrypt renamed ``text_opts`` to ``string_opts`` in 1.19 and takes only + one of the two, and the preview query types still run against pymongocrypt + before 1.19, so both spellings are in play across the matrix. + """ + return {encryption._string_opts_kwarg(): StringOpts(**kwargs)} + + class TestStringOptsDeprecation(PyMongoTestCase): def test_text_opts_is_still_re_exported(self): # TextOpts is deprecated, not removed, so it must stay importable from @@ -252,30 +262,36 @@ def test_text_opts_is_deprecated(self): opts.document, ) - def test_resolve_string_opts(self): - string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + def test_resolve_string_opts_no_opts(self): self.assertIsNone(encryption._resolve_string_opts(None, None)) - self.assertIs(encryption._resolve_string_opts(string_opts, None), string_opts) - - def test_resolve_string_opts_text_opts_is_deprecated(self): - string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) - with self.assertWarns(DeprecationWarning): - self.assertIs(encryption._resolve_string_opts(None, string_opts), string_opts) def test_resolve_string_opts_rejects_both(self): string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) with self.assertRaises(ConfigurationError): encryption._resolve_string_opts(string_opts, string_opts) + @unittest.skipUnless(_HAVE_PYMONGOCRYPT, "pymongocrypt is not installed") + def test_resolve_string_opts_follows_pymongocrypt(self): + # pymongocrypt accepts only one of the two names per release: text_opts + # through 1.18 and string_opts from 1.19. Passing the name the + # installed binding does not support is an error rather than a silent + # alias, so assert against the resolved name. + string_opts = StringOpts(prefix={"strMinQueryLength": 2, "strMaxQueryLength": 10}) + if encryption._string_opts_kwarg() == "string_opts": + supported, unsupported = (string_opts, None), (None, string_opts) + else: + supported, unsupported = (None, string_opts), (string_opts, None) + self.assertIs(encryption._resolve_string_opts(*supported), string_opts) + with self.assertRaises(ConfigurationError): + encryption._resolve_string_opts(*unsupported) + @unittest.skipUnless(_HAVE_PYMONGOCRYPT, "pymongocrypt is not installed") def test_string_opts_kwarg_matches_binding(self): - # pymongocrypt renamed text_opts to string_opts in 1.19, and both - # spellings are still in play: the GA query types need a master build - # and the preview query types run against pymongocrypt < 1.19. Assert - # the resolved name against the installed binding here so a mismatch - # fails without a server, rather than only in the prose suite. + # The resolved name is passed straight through to pymongocrypt, so + # assert it against the installed binding here: a mismatch then fails + # without a server, rather than only in the prose suite. params = inspect.signature(encryption.ExplicitEncrypter.encrypt).parameters - self.assertIn(encryption._STRING_OPTS_KWARG, params) + self.assertIn(encryption._string_opts_kwarg(), params) class EncryptionIntegrationTest(IntegrationTest): @@ -3470,7 +3486,7 @@ def setUp(self): key_id=self.key1_id, algorithm=self.algorithm, contention_factor=0, - string_opts=StringOpts( + **_string_opts_kwargs( case_sensitive=True, diacritic_sensitive=True, prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), @@ -3492,7 +3508,7 @@ def setUp(self): key_id=self.key1_id, algorithm=self.algorithm, contention_factor=0, - string_opts=StringOpts( + **_string_opts_kwargs( case_sensitive=True, diacritic_sensitive=True, substring=dict(strMaxLength=10, strMaxQueryLength=6, strMinQueryLength=2), @@ -3557,7 +3573,7 @@ def _encrypt(self, value, query_type=None, **string_opts): algorithm=self.algorithm, query_type=query_type, contention_factor=0, - string_opts=StringOpts(**string_opts), + **_string_opts_kwargs(**string_opts), ) def _find(self, collection, filter): @@ -3701,7 +3717,7 @@ def test_07_contentionFactor_is_required(self): key_id=self.key1_id, algorithm=Algorithm.STRING, query_type=QueryType.PREFIX, - string_opts=StringOpts( + **_string_opts_kwargs( case_sensitive=True, diacritic_sensitive=True, prefix=dict(strMaxQueryLength=10, strMinQueryLength=2), From 0ee8ecb325f8731b157445c0328305e7e70d21b9 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 20 Aug 2026 16:22:35 -0400 Subject: [PATCH 8/8] PYTHON-5909 Quote the pymongocrypt version pin in UV_ARGS --- .evergreen/scripts/setup_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index c28f724113..feeb770c39 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -401,7 +401,7 @@ def handle_test_env() -> None: if not opts.test_min_deps: if use_pymongocrypt_text_preview: - UV_ARGS.append("--with pymongocrypt<1.19") + UV_ARGS.append("--with 'pymongocrypt<1.19'") else: UV_ARGS.append( "--with pymongocrypt@git+https://github.com/mongodb/libmongocrypt@master#subdirectory=bindings/python"