Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ Changes in Version 4.15.0 (XXXX/XX/XX)
--------------------------------------
PyMongo 4.15 brings a number of changes including:

- Added :class:`~pymongo.encryption_options.TextOpts`,
:attr:`~pymongo.encryption.Algorithm.TEXTPREVIEW`,
:attr:`~pymongo.encryption.QueryType.PREFIXPREVIEW`,
:attr:`~pymongo.encryption.QueryType.SUFFIXPREVIEW`,
:attr:`~pymongo.encryption.QueryType.SUBSTRINGPREVIEW`,
as part of the experimental Queryable Encryption text queries beta.
``pymongocrypt>=1.16`` is required for text query support.
- Added :class:`bson.decimal128.DecimalEncoder` and :class:`bson.decimal128.DecimalDecoder`
to support encoding and decoding of BSON Decimal128 values to decimal.Decimal values using the TypeRegistry API.

Expand Down
40 changes: 39 additions & 1 deletion pymongo/asynchronous/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
from pymongo.asynchronous.pool import AsyncBaseConnection
from pymongo.common import CONNECT_TIMEOUT
from pymongo.daemon import _spawn_daemon
from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts
from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts, TextOpts
from pymongo.errors import (
ConfigurationError,
EncryptedCollectionError,
Expand Down Expand Up @@ -516,6 +516,11 @@ class Algorithm(str, enum.Enum):

.. versionadded:: 4.4
"""
TEXTPREVIEW = "TextPreview"
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we required to call this TEXTPREVIEW instead of calling it TEXT and designating "feature preview"?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, following the pattern we used for RANGEPREVIEW, to ensure that if the semantics change it won't do so silently.

"""**BETA** - TextPreview.

.. versionadded:: 4.15
"""


class QueryType(str, enum.Enum):
Expand All @@ -541,6 +546,24 @@ class QueryType(str, enum.Enum):
.. versionadded:: 4.4
"""

PREFIXPREVIEW = "prefixPreview"
"""**BETA** - Used to encrypt a value for a prefixPreview query.

.. versionadded:: 4.15
"""

SUFFIXPREVIEW = "suffixPreview"
"""**BETA** - Used to encrypt a value for a suffixPreview query.

.. versionadded:: 4.15
"""

SUBSTRINGPREVIEW = "substringPreview"
"""**BETA** - Used to encrypt a value for a substringPreview query.

.. versionadded:: 4.15
"""


def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions:
# For compat with pymongocrypt <1.13, avoid setting the default key_expiration_ms.
Expand Down Expand Up @@ -876,6 +899,7 @@ async def _encrypt_helper(
contention_factor: Optional[int] = None,
range_opts: Optional[RangeOpts] = None,
is_expression: bool = False,
text_opts: Optional[TextOpts] = None,
) -> Any:
self._check_closed()
if isinstance(key_id, uuid.UUID):
Expand All @@ -895,6 +919,12 @@ 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,
codec_options=self._codec_options,
)
with _wrap_encryption_errors():
encrypted_doc = await self._encryption.encrypt(
value=doc,
Expand All @@ -905,6 +935,7 @@ async def _encrypt_helper(
contention_factor=contention_factor,
range_opts=range_opts_bytes,
is_expression=is_expression,
text_opts=text_opts_bytes,
Copy link
Member

Choose a reason for hiding this comment

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

Passing text_opts even when it's None will fail on old versions of pymongocrypt. So we need to bump the min version or avoid passing text_opts by default.

)
return decode(encrypted_doc)["v"]

Expand All @@ -917,6 +948,7 @@ async def encrypt(
query_type: Optional[str] = None,
contention_factor: Optional[int] = None,
range_opts: Optional[RangeOpts] = None,
text_opts: Optional[TextOpts] = None,
) -> Binary:
"""Encrypt a BSON value with a given key and algorithm.

Expand All @@ -937,9 +969,14 @@ 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.

:return: The encrypted value, a :class:`~bson.binary.Binary` with subtype 6.

.. versionchanged:: 4.9
Added the `text_opts` parameter.

.. versionchanged:: 4.9
Added the `range_opts` parameter.

Expand All @@ -960,6 +997,7 @@ async def encrypt(
contention_factor=contention_factor,
range_opts=range_opts,
is_expression=False,
text_opts=text_opts,
),
)

Expand Down
84 changes: 83 additions & 1 deletion pymongo/encryption_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"""
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Mapping, Optional
from typing import TYPE_CHECKING, Any, Mapping, Optional, TypedDict

from pymongo.uri_parser_shared import _parse_kms_tls_options

Expand Down Expand Up @@ -295,3 +295,85 @@ def document(self) -> dict[str, Any]:
if v is not None:
doc[k] = v
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."""

def __init__(
self,
substring: Optional[SubstringOpts] = None,
prefix: Optional[PrefixOpts] = None,
suffix: Optional[SuffixOpts] = None,
case_sensitive: Optional[bool] = None,
diacritic_sensitive: Optional[bool] = None,
) -> None:
"""Options to configure encrypted queries using the text 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.

.. versionadded:: 4.15
"""
self.substring = substring
self.prefix = prefix
self.suffix = suffix
self.case_sensitive = case_sensitive
self.diacritic_sensitive = diacritic_sensitive

@property
def document(self) -> dict[str, Any]:
doc = {}
for k, v in [
("substring", self.substring),
("prefix", self.prefix),
("suffix", self.suffix),
("caseSensitive", self.case_sensitive),
("diacriticSensitive", self.diacritic_sensitive),
]:
if v is not None:
doc[k] = v
return doc


class SubstringOpts(TypedDict):
"""**BETA** Options for substring text queries.

SubstringOpts is currently unstable API and subject to backwards breaking changes.
"""

# strMaxLength is the maximum allowed length to insert. Inserting longer strings will error.
strMaxLength: int
# 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 PrefixOpts(TypedDict):
"""**BETA** Options for prefix text queries.

PrefixOpts is currently unstable API and subject to backwards breaking changes.
"""

# 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 SuffixOpts(TypedDict):
"""**BETA** Options for suffix text queries.

SuffixOpts is currently unstable API and subject to backwards breaking changes.
"""

# 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
40 changes: 39 additions & 1 deletion pymongo/synchronous/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
from pymongo import _csot
from pymongo.common import CONNECT_TIMEOUT
from pymongo.daemon import _spawn_daemon
from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts
from pymongo.encryption_options import AutoEncryptionOpts, RangeOpts, TextOpts
from pymongo.errors import (
ConfigurationError,
EncryptedCollectionError,
Expand Down Expand Up @@ -513,6 +513,11 @@ class Algorithm(str, enum.Enum):

.. versionadded:: 4.4
"""
TEXTPREVIEW = "TextPreview"
"""**BETA** - TextPreview.

.. versionadded:: 4.15
"""


class QueryType(str, enum.Enum):
Expand All @@ -538,6 +543,24 @@ class QueryType(str, enum.Enum):
.. versionadded:: 4.4
"""

PREFIXPREVIEW = "prefixPreview"
"""**BETA** - Used to encrypt a value for a prefixPreview query.

.. versionadded:: 4.15
"""

SUFFIXPREVIEW = "suffixPreview"
"""**BETA** - Used to encrypt a value for a suffixPreview query.

.. versionadded:: 4.15
"""

SUBSTRINGPREVIEW = "substringPreview"
"""**BETA** - Used to encrypt a value for a substringPreview query.

.. versionadded:: 4.15
"""


def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions:
# For compat with pymongocrypt <1.13, avoid setting the default key_expiration_ms.
Expand Down Expand Up @@ -869,6 +892,7 @@ def _encrypt_helper(
contention_factor: Optional[int] = None,
range_opts: Optional[RangeOpts] = None,
is_expression: bool = False,
text_opts: Optional[TextOpts] = None,
) -> Any:
self._check_closed()
if isinstance(key_id, uuid.UUID):
Expand All @@ -888,6 +912,12 @@ 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,
codec_options=self._codec_options,
)
with _wrap_encryption_errors():
encrypted_doc = self._encryption.encrypt(
value=doc,
Expand All @@ -898,6 +928,7 @@ def _encrypt_helper(
contention_factor=contention_factor,
range_opts=range_opts_bytes,
is_expression=is_expression,
text_opts=text_opts_bytes,
)
return decode(encrypted_doc)["v"]

Expand All @@ -910,6 +941,7 @@ def encrypt(
query_type: Optional[str] = None,
contention_factor: Optional[int] = None,
range_opts: Optional[RangeOpts] = None,
text_opts: Optional[TextOpts] = None,
) -> Binary:
"""Encrypt a BSON value with a given key and algorithm.

Expand All @@ -930,9 +962,14 @@ 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.

:return: The encrypted value, a :class:`~bson.binary.Binary` with subtype 6.

.. versionchanged:: 4.9
Added the `text_opts` parameter.

.. versionchanged:: 4.9
Added the `range_opts` parameter.

Expand All @@ -953,6 +990,7 @@ def encrypt(
contention_factor=contention_factor,
range_opts=range_opts,
is_expression=False,
text_opts=text_opts,
),
)

Expand Down
14 changes: 14 additions & 0 deletions test/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import warnings
from inspect import iscoroutinefunction

from pymongo.encryption_options import _HAVE_PYMONGOCRYPT
from pymongo.errors import AutoReconnect
from pymongo.synchronous.uri_parser import parse_uri

Expand Down Expand Up @@ -524,6 +525,19 @@ def require_version_max(self, *ver):
"Server version must be at most %s" % str(other_version),
)

def require_libmongocrypt_min(self, *ver):
other_version = Version(*ver)
if not _HAVE_PYMONGOCRYPT:
version = Version.from_string("0.0.0")
else:
from pymongocrypt import libmongocrypt_version

version = Version.from_string(libmongocrypt_version())
return self._require(
lambda: version >= other_version,
"Libmongocrypt version must be at least %s" % str(other_version),
)

def require_auth(self, func):
"""Run a test only if the server is running with auth enabled."""
return self._require(
Expand Down
14 changes: 14 additions & 0 deletions test/asynchronous/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from inspect import iscoroutinefunction

from pymongo.asynchronous.uri_parser import parse_uri
from pymongo.encryption_options import _HAVE_PYMONGOCRYPT
from pymongo.errors import AutoReconnect

try:
Expand Down Expand Up @@ -524,6 +525,19 @@ def require_version_max(self, *ver):
"Server version must be at most %s" % str(other_version),
)

def require_libmongocrypt_min(self, *ver):
other_version = Version(*ver)
if not _HAVE_PYMONGOCRYPT:
version = Version.from_string("0.0.0")
else:
from pymongocrypt import libmongocrypt_version

version = Version.from_string(libmongocrypt_version())
return self._require(
lambda: version >= other_version,
"Libmongocrypt version must be at least %s" % str(other_version),
)

def require_auth(self, func):
"""Run a test only if the server is running with auth enabled."""
return self._require(
Expand Down
Loading
Loading