From e2107c22dd18d65dceb24f22541d59f0dad2c732 Mon Sep 17 00:00:00 2001 From: "mongodb-dbx-release-bot[bot]" <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 17:31:27 +0000 Subject: [PATCH 01/20] Prep branch v4.15 Signed-off-by: mongodb-dbx-release-bot[bot] <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> --- .github/workflows/release-python.yml | 2 +- pymongo/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index 9cce310d91..cbb2322fe8 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -16,7 +16,7 @@ env: # Changes per repo PRODUCT_NAME: PyMongo # Changes per branch - EVERGREEN_PROJECT: mongo-python-driver + EVERGREEN_PROJECT: mongo-python-driver-release # Constant # inputs will be empty on a scheduled run. so, we only set dry_run # to 'false' when the input is set to 'false'. diff --git a/pymongo/_version.py b/pymongo/_version.py index c6ba82ab13..88ad5e2056 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.16.0.dev0" +__version__ = "4.15.1.dev0" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: From bc4ee39aac5b013cf1b392c4cb76639517d8e218 Mon Sep 17 00:00:00 2001 From: "mongodb-dbx-release-bot[bot]" <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 05:32:34 -0500 Subject: [PATCH 02/20] PYTHON-5540 Fix usage of text_opts for older versions of pymongocrypt (#2525) [v4.15] (#2526) Co-authored-by: Steven Silvester --- pymongo/asynchronous/encryption.py | 3 ++- pymongo/synchronous/encryption.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index b302631108..662f2abd5b 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -935,7 +935,8 @@ async def _encrypt_helper( contention_factor=contention_factor, range_opts=range_opts_bytes, is_expression=is_expression, - text_opts=text_opts_bytes, + # For compatibility with pymongocrypt < 1.16: + **{"text_opts": text_opts_bytes} if text_opts_bytes else {}, ) return decode(encrypted_doc)["v"] diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index 752026af84..518a16ff2d 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -928,7 +928,8 @@ def _encrypt_helper( contention_factor=contention_factor, range_opts=range_opts_bytes, is_expression=is_expression, - text_opts=text_opts_bytes, + # For compatibility with pymongocrypt < 1.16: + **{"text_opts": text_opts_bytes} if text_opts_bytes else {}, ) return decode(encrypted_doc)["v"] From ebe8bfb564c3c6f42d108ab1b4012a2bf38a7cd0 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 11 Sep 2025 15:41:31 -0500 Subject: [PATCH 03/20] PYTHON-5542 Prepare for 4.15.1 Release (#2527) --- doc/changelog.rst | 18 ++++++++++++++++++ pymongo/_version.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 64c61e5877..cf84843eb7 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,24 @@ Changelog ========= +Changes in Version 4.15.1 (2025/09/11) +-------------------------------------- + +Version 4.15.1 is a bug fix release. + + - Fixed a bug in :meth:`~pymongo.synchronous.encryption.ClientEncryption.encrypt` and :meth:`~pymongo.asynchronous.encryption.AsyncClientEncryption.encrypt` + that would cause a ``TypeError`` when using ``pymongocrypt<1.16`` by passing an unsupported ``type_opts`` parameter even if + Queryable Encryption text queries beta was not used. + +Issues Resolved +............... + +See the `PyMongo 4.15.1 release notes in JIRA`_ for the list of resolved issues +in this release. + +.. _PyMongo 4.15.1 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=46486 + + Changes in Version 4.15.0 (2025/09/10) -------------------------------------- diff --git a/pymongo/_version.py b/pymongo/_version.py index 88ad5e2056..7abecf4416 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.15.1.dev0" +__version__ = "4.15.1" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: From 7b2b221eec837ff49a6fe194379107eb8b9b224b Mon Sep 17 00:00:00 2001 From: "Jeffrey A. Clark" Date: Fri, 12 Sep 2025 12:52:51 -0400 Subject: [PATCH 04/20] Prep 4.15.1 (#2530) Co-authored-by: Noah Stapp --- doc/changelog.rst | 23 +++++++++++++++-------- pymongo/network_layer.py | 4 ++-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index cf84843eb7..c8e17bc30b 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -6,9 +6,14 @@ Changes in Version 4.15.1 (2025/09/11) Version 4.15.1 is a bug fix release. - - Fixed a bug in :meth:`~pymongo.synchronous.encryption.ClientEncryption.encrypt` and :meth:`~pymongo.asynchronous.encryption.AsyncClientEncryption.encrypt` - that would cause a ``TypeError`` when using ``pymongocrypt<1.16`` by passing an unsupported ``type_opts`` parameter even if - Queryable Encryption text queries beta was not used. +- Fixed a bug in :meth:`~pymongo.synchronous.encryption.ClientEncryption.encrypt` + and :meth:`~pymongo.asynchronous.encryption.AsyncClientEncryption.encrypt` + that would cause a ``TypeError`` when using ``pymongocrypt<1.16`` by passing + an unsupported ``type_opts`` parameter even if Queryable Encryption text + queries beta was not used. + +- Fixed a bug in ``AsyncMongoClient`` that caused a ``ServerSelectionTimeoutError`` + when used with ``uvicorn``, ``FastAPI``, or ``uvloop``. Issues Resolved ............... @@ -18,7 +23,6 @@ in this release. .. _PyMongo 4.15.1 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=46486 - Changes in Version 4.15.0 (2025/09/10) -------------------------------------- @@ -31,8 +35,10 @@ PyMongo 4.15 brings a number of changes including: :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. +- 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. - Added support for Windows ``arm64`` wheels. Changes in Version 4.14.1 (2025/08/19) @@ -40,8 +46,9 @@ Changes in Version 4.14.1 (2025/08/19) Version 4.14.1 is a bug fix release. - - Fixed a bug in ``MongoClient.append_metadata()`` and ``AsyncMongoClient.append_metadata()`` - that allowed duplicate ``DriverInfo.name`` to be appended to the metadata. +- Fixed a bug in ``MongoClient.append_metadata()`` and + ``AsyncMongoClient.append_metadata()`` + that allowed duplicate ``DriverInfo.name`` to be appended to the metadata. Issues Resolved ............... diff --git a/pymongo/network_layer.py b/pymongo/network_layer.py index 605b8dde9b..9173130776 100644 --- a/pymongo/network_layer.py +++ b/pymongo/network_layer.py @@ -22,7 +22,7 @@ import struct import sys import time -from asyncio import BaseTransport, BufferedProtocol, Future, Protocol, Transport +from asyncio import BaseProtocol, BaseTransport, BufferedProtocol, Future, Transport from typing import ( TYPE_CHECKING, Any, @@ -250,7 +250,7 @@ def recv_into(self, buffer: bytes) -> int: return self.conn.recv_into(buffer) -class PyMongoBaseProtocol(Protocol): +class PyMongoBaseProtocol(BaseProtocol): def __init__(self, timeout: Optional[float] = None): self.transport: Transport = None # type: ignore[assignment] self._timeout = timeout From 09fa287839e7930bb77c87b9d7d2075cce2de6d5 Mon Sep 17 00:00:00 2001 From: "mongodb-dbx-release-bot[bot]" <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:16:22 -0500 Subject: [PATCH 05/20] PYTHON-5537 Update typing dependencies (#2524) [v4.15] (#2536) Co-authored-by: Steven Silvester --- bson/binary.py | 2 +- bson/raw_bson.py | 14 +++-- bson/son.py | 2 +- bson/typings.py | 2 +- pymongo/asynchronous/cursor.py | 2 +- pymongo/asynchronous/encryption.py | 6 +- pymongo/asynchronous/helpers.py | 2 +- pymongo/compression_support.py | 2 +- pymongo/message.py | 16 ++--- pymongo/network_layer.py | 9 +-- pymongo/synchronous/cursor.py | 2 +- pymongo/synchronous/encryption.py | 6 +- pymongo/synchronous/helpers.py | 2 +- test/test_typing.py | 4 +- test/unified_format_shared.py | 6 +- uv.lock | 98 +++++++++++++++++------------- 16 files changed, 100 insertions(+), 75 deletions(-) diff --git a/bson/binary.py b/bson/binary.py index b48ae4fcc6..48eb12b0ac 100644 --- a/bson/binary.py +++ b/bson/binary.py @@ -298,7 +298,7 @@ class Binary(bytes): def __new__( cls: Type[Binary], - data: Union[memoryview, bytes, _mmap, _array[Any]], + data: Union[memoryview, bytes, bytearray, _mmap, _array[Any]], subtype: int = BINARY_SUBTYPE, ) -> Binary: if not isinstance(subtype, int): diff --git a/bson/raw_bson.py b/bson/raw_bson.py index 2ce53143c2..9ead0765dc 100644 --- a/bson/raw_bson.py +++ b/bson/raw_bson.py @@ -60,7 +60,9 @@ def _inflate_bson( - bson_bytes: bytes, codec_options: CodecOptions[RawBSONDocument], raw_array: bool = False + bson_bytes: bytes | memoryview, + codec_options: CodecOptions[RawBSONDocument], + raw_array: bool = False, ) -> dict[str, Any]: """Inflates the top level fields of a BSON document. @@ -85,7 +87,9 @@ class RawBSONDocument(Mapping[str, Any]): __codec_options: CodecOptions[RawBSONDocument] def __init__( - self, bson_bytes: bytes, codec_options: Optional[CodecOptions[RawBSONDocument]] = None + self, + bson_bytes: bytes | memoryview, + codec_options: Optional[CodecOptions[RawBSONDocument]] = None, ) -> None: """Create a new :class:`RawBSONDocument` @@ -135,7 +139,7 @@ class from the standard library so it can be used like a read-only _get_object_size(bson_bytes, 0, len(bson_bytes)) @property - def raw(self) -> bytes: + def raw(self) -> bytes | memoryview: """The raw BSON bytes composing this document.""" return self.__raw @@ -153,7 +157,7 @@ def __inflated(self) -> Mapping[str, Any]: @staticmethod def _inflate_bson( - bson_bytes: bytes, codec_options: CodecOptions[RawBSONDocument] + bson_bytes: bytes | memoryview, codec_options: CodecOptions[RawBSONDocument] ) -> Mapping[str, Any]: return _inflate_bson(bson_bytes, codec_options) @@ -180,7 +184,7 @@ class _RawArrayBSONDocument(RawBSONDocument): @staticmethod def _inflate_bson( - bson_bytes: bytes, codec_options: CodecOptions[RawBSONDocument] + bson_bytes: bytes | memoryview, codec_options: CodecOptions[RawBSONDocument] ) -> Mapping[str, Any]: return _inflate_bson(bson_bytes, codec_options, raw_array=True) diff --git a/bson/son.py b/bson/son.py index 24275fce16..8fd4f95cd2 100644 --- a/bson/son.py +++ b/bson/son.py @@ -143,7 +143,7 @@ def popitem(self) -> Tuple[_Key, _Value]: del self[k] return (k, v) - def update(self, other: Optional[Any] = None, **kwargs: _Value) -> None: # type: ignore[override] + def update(self, other: Optional[Any] = None, **kwargs: _Value) -> None: # Make progressively weaker assumptions about "other" if other is None: pass diff --git a/bson/typings.py b/bson/typings.py index 55e90b19a5..5913860556 100644 --- a/bson/typings.py +++ b/bson/typings.py @@ -28,4 +28,4 @@ _DocumentOut = Union[MutableMapping[str, Any], "RawBSONDocument"] _DocumentType = TypeVar("_DocumentType", bound=Mapping[str, Any]) _DocumentTypeArg = TypeVar("_DocumentTypeArg", bound=Mapping[str, Any]) -_ReadableBuffer = Union[bytes, memoryview, "mmap", "array"] # type: ignore[type-arg] +_ReadableBuffer = Union[bytes, memoryview, bytearray, "mmap", "array"] # type: ignore[type-arg] diff --git a/pymongo/asynchronous/cursor.py b/pymongo/asynchronous/cursor.py index ab2d0e873c..d9fdd576f4 100644 --- a/pymongo/asynchronous/cursor.py +++ b/pymongo/asynchronous/cursor.py @@ -1009,7 +1009,7 @@ def _deepcopy( else: if not isinstance(key, RE_TYPE): key = copy.deepcopy(key, memo) # noqa: PLW2901 - y[key] = value + y[key] = value # type:ignore[index] return y def _prepare_to_die(self, already_killed: bool) -> tuple[int, Optional[_CursorAddress]]: diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index 662f2abd5b..2b1895b832 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -264,7 +264,7 @@ def spawn(self) -> None: args.extend(self.opts._mongocryptd_spawn_args) _spawn_daemon(args) - async def mark_command(self, database: str, cmd: bytes) -> bytes: + async def mark_command(self, database: str, cmd: bytes) -> bytes | memoryview: """Mark a command for encryption. :param database: The database on which to run this command. @@ -291,7 +291,7 @@ async def mark_command(self, database: str, cmd: bytes) -> bytes: ) return res.raw - async def fetch_keys(self, filter: bytes) -> AsyncGenerator[bytes, None]: + async def fetch_keys(self, filter: bytes) -> AsyncGenerator[bytes | memoryview, None]: """Yields one or more keys from the key vault. :param filter: The filter to pass to find. @@ -463,7 +463,7 @@ async def encrypt( # TODO: PYTHON-1922 avoid decoding the encrypted_cmd. return _inflate_bson(encrypted_cmd, DEFAULT_RAW_BSON_OPTIONS) - async def decrypt(self, response: bytes) -> Optional[bytes]: + async def decrypt(self, response: bytes | memoryview) -> Optional[bytes]: """Decrypt a MongoDB command response. :param response: A MongoDB command response as BSON. diff --git a/pymongo/asynchronous/helpers.py b/pymongo/asynchronous/helpers.py index 54fd64f74a..ccda16e28b 100644 --- a/pymongo/asynchronous/helpers.py +++ b/pymongo/asynchronous/helpers.py @@ -78,7 +78,7 @@ async def _getaddrinfo( socket.SocketKind, int, str, - tuple[str, int] | tuple[str, int, int, int], + tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes], ] ]: if not _IS_SYNC: diff --git a/pymongo/compression_support.py b/pymongo/compression_support.py index db14b8d83f..64ffe052ec 100644 --- a/pymongo/compression_support.py +++ b/pymongo/compression_support.py @@ -152,7 +152,7 @@ def compress(data: bytes) -> bytes: return zstandard.ZstdCompressor().compress(data) -def decompress(data: bytes, compressor_id: int) -> bytes: +def decompress(data: bytes | memoryview, compressor_id: int) -> bytes: if compressor_id == SnappyContext.compressor_id: # python-snappy doesn't support the buffer interface. # https://github.com/andrix/python-snappy/issues/65 diff --git a/pymongo/message.py b/pymongo/message.py index b2e5a685af..0f3aaaba77 100644 --- a/pymongo/message.py +++ b/pymongo/message.py @@ -1352,7 +1352,9 @@ class _OpReply: UNPACK_FROM = struct.Struct(" list[bytes]: + ) -> list[bytes | memoryview]: """Check the response header from the database, without decoding BSON. Check the response for errors and unpack. @@ -1448,7 +1450,7 @@ def more_to_come(self) -> bool: return False @classmethod - def unpack(cls, msg: bytes) -> _OpReply: + def unpack(cls, msg: bytes | memoryview) -> _OpReply: """Construct an _OpReply from raw bytes.""" # PYTHON-945: ignore starting_from field. flags, cursor_id, _, number_returned = cls.UNPACK_FROM(msg) @@ -1470,7 +1472,7 @@ class _OpMsg: MORE_TO_COME = 1 << 1 EXHAUST_ALLOWED = 1 << 16 # Only present on requests. - def __init__(self, flags: int, payload_document: bytes): + def __init__(self, flags: int, payload_document: bytes | memoryview): self.flags = flags self.payload_document = payload_document @@ -1512,7 +1514,7 @@ def command_response(self, codec_options: CodecOptions[Any]) -> dict[str, Any]: """Unpack a command response.""" return self.unpack_response(codec_options=codec_options)[0] - def raw_command_response(self) -> bytes: + def raw_command_response(self) -> bytes | memoryview: """Return the bytes of the command response.""" return self.payload_document @@ -1522,7 +1524,7 @@ def more_to_come(self) -> bool: return bool(self.flags & self.MORE_TO_COME) @classmethod - def unpack(cls, msg: bytes) -> _OpMsg: + def unpack(cls, msg: bytes | memoryview) -> _OpMsg: """Construct an _OpMsg from raw bytes.""" flags, first_payload_type, first_payload_size = cls.UNPACK_FROM(msg) if flags != 0: @@ -1541,7 +1543,7 @@ def unpack(cls, msg: bytes) -> _OpMsg: return cls(flags, payload_document) -_UNPACK_REPLY: dict[int, Callable[[bytes], Union[_OpReply, _OpMsg]]] = { +_UNPACK_REPLY: dict[int, Callable[[bytes | memoryview], Union[_OpReply, _OpMsg]]] = { _OpReply.OP_CODE: _OpReply.unpack, _OpMsg.OP_CODE: _OpMsg.unpack, } diff --git a/pymongo/network_layer.py b/pymongo/network_layer.py index 9173130776..a3900e30c1 100644 --- a/pymongo/network_layer.py +++ b/pymongo/network_layer.py @@ -246,7 +246,7 @@ def sock(self) -> Union[socket.socket, _sslConn]: def fileno(self) -> int: return self.conn.fileno() - def recv_into(self, buffer: bytes) -> int: + def recv_into(self, buffer: bytes | memoryview) -> int: return self.conn.recv_into(buffer) @@ -533,14 +533,14 @@ def _resolve_pending(self, exc: Optional[Exception] = None) -> None: fut = self._pending_listeners.popleft() fut.set_result(b"") - def _read(self, bytes_needed: int) -> memoryview: + def _read(self, bytes_needed: int) -> bytes: """Read bytes.""" # Send the bytes to the listener. if self._bytes_ready < bytes_needed: bytes_needed = self._bytes_ready self._bytes_ready -= bytes_needed - output_buf = bytearray(bytes_needed) + output_buf = memoryview(bytearray(bytes_needed)) n_remaining = bytes_needed out_index = 0 while n_remaining > 0: @@ -557,7 +557,7 @@ def _read(self, bytes_needed: int) -> memoryview: output_buf[out_index : out_index + buf_size] = buffer[:] out_index += buf_size n_remaining -= buf_size - return memoryview(output_buf) + return bytes(output_buf) async def async_sendall(conn: PyMongoBaseProtocol, buf: bytes) -> None: @@ -670,6 +670,7 @@ def receive_message( f"Message length ({length!r}) is larger than server max " f"message size ({max_message_size!r})" ) + data: bytes | memoryview if op_code == 2012: op_code, _, compressor_id = _UNPACK_COMPRESSION_HEADER(receive_data(conn, 9, deadline)) data = decompress(receive_data(conn, length - 25, deadline), compressor_id) diff --git a/pymongo/synchronous/cursor.py b/pymongo/synchronous/cursor.py index eb45d9c5d1..3dd550f4d5 100644 --- a/pymongo/synchronous/cursor.py +++ b/pymongo/synchronous/cursor.py @@ -1007,7 +1007,7 @@ def _deepcopy( else: if not isinstance(key, RE_TYPE): key = copy.deepcopy(key, memo) # noqa: PLW2901 - y[key] = value + y[key] = value # type:ignore[index] return y def _prepare_to_die(self, already_killed: bool) -> tuple[int, Optional[_CursorAddress]]: diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index 518a16ff2d..a08302c211 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -261,7 +261,7 @@ def spawn(self) -> None: args.extend(self.opts._mongocryptd_spawn_args) _spawn_daemon(args) - def mark_command(self, database: str, cmd: bytes) -> bytes: + def mark_command(self, database: str, cmd: bytes) -> bytes | memoryview: """Mark a command for encryption. :param database: The database on which to run this command. @@ -288,7 +288,7 @@ def mark_command(self, database: str, cmd: bytes) -> bytes: ) return res.raw - def fetch_keys(self, filter: bytes) -> Generator[bytes, None]: + def fetch_keys(self, filter: bytes) -> Generator[bytes | memoryview, None]: """Yields one or more keys from the key vault. :param filter: The filter to pass to find. @@ -460,7 +460,7 @@ def encrypt( # TODO: PYTHON-1922 avoid decoding the encrypted_cmd. return _inflate_bson(encrypted_cmd, DEFAULT_RAW_BSON_OPTIONS) - def decrypt(self, response: bytes) -> Optional[bytes]: + def decrypt(self, response: bytes | memoryview) -> Optional[bytes]: """Decrypt a MongoDB command response. :param response: A MongoDB command response as BSON. diff --git a/pymongo/synchronous/helpers.py b/pymongo/synchronous/helpers.py index bc69a49e80..1fff9a0f23 100644 --- a/pymongo/synchronous/helpers.py +++ b/pymongo/synchronous/helpers.py @@ -78,7 +78,7 @@ def _getaddrinfo( socket.SocketKind, int, str, - tuple[str, int] | tuple[str, int, int, int], + tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes], ] ]: if not _IS_SYNC: diff --git a/test/test_typing.py b/test/test_typing.py index 65937020d2..8709186e12 100644 --- a/test/test_typing.py +++ b/test/test_typing.py @@ -475,7 +475,7 @@ def test_typeddict_not_required_document_type(self) -> None: # This should fail because the output is a Movie. assert out["foo"] # type:ignore[typeddict-item] # pyright gives reportTypedDictNotRequiredAccess for the following: - assert out["_id"] # type:ignore + assert out["_id"] # type:ignore[unused-ignore] @only_type_check def test_typeddict_empty_document_type(self) -> None: @@ -496,7 +496,7 @@ def test_typeddict_find_notrequired(self): out = coll.find_one({}) assert out is not None # pyright gives reportTypedDictNotRequiredAccess for the following: - assert out["_id"] # type:ignore + assert out["_id"] # type:ignore[unused-ignore] @only_type_check def test_raw_bson_document_type(self) -> None: diff --git a/test/unified_format_shared.py b/test/unified_format_shared.py index 7ebcc6eb20..5aa989cb24 100644 --- a/test/unified_format_shared.py +++ b/test/unified_format_shared.py @@ -37,7 +37,7 @@ LOCAL_MASTER_KEY, ) from test.utils_shared import CMAPListener, camel_to_snake, parse_collection_options -from typing import Any, Union +from typing import Any, MutableMapping, Union from bson import ( RE_TYPE, @@ -162,7 +162,9 @@ def __new__(cls, name, this_bases, d): return meta(name, resolved_bases, d) @classmethod - def __prepare__(cls, name, this_bases): + def __prepare__( + cls, name: str, this_bases: tuple[type, ...], /, **kwds: Any + ) -> MutableMapping[str, object]: return meta.__prepare__(name, bases) return type.__new__(metaclass, "temporary_class", (), {}) diff --git a/uv.lock b/uv.lock index 2e0ef2a151..77f6a46385 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.9" resolution-markers = [ "python_full_version == '3.14.*'", @@ -1047,46 +1047,53 @@ dependencies = [ [[package]] name = "mypy" -version = "1.14.1" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, + { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051, upload-time = "2024-12-30T16:39:07.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/7a/87ae2adb31d68402da6da1e5f30c07ea6063e9f09b5e7cfc9dfa44075e74/mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb", size = 11211002, upload-time = "2024-12-30T16:37:22.435Z" }, - { url = "https://files.pythonhosted.org/packages/e1/23/eada4c38608b444618a132be0d199b280049ded278b24cbb9d3fc59658e4/mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0", size = 10358400, upload-time = "2024-12-30T16:37:53.526Z" }, - { url = "https://files.pythonhosted.org/packages/43/c9/d6785c6f66241c62fd2992b05057f404237deaad1566545e9f144ced07f5/mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d", size = 12095172, upload-time = "2024-12-30T16:37:50.332Z" }, - { url = "https://files.pythonhosted.org/packages/c3/62/daa7e787770c83c52ce2aaf1a111eae5893de9e004743f51bfcad9e487ec/mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b", size = 12828732, upload-time = "2024-12-30T16:37:29.96Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a2/5fb18318a3637f29f16f4e41340b795da14f4751ef4f51c99ff39ab62e52/mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427", size = 13012197, upload-time = "2024-12-30T16:38:05.037Z" }, - { url = "https://files.pythonhosted.org/packages/28/99/e153ce39105d164b5f02c06c35c7ba958aaff50a2babba7d080988b03fe7/mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f", size = 9780836, upload-time = "2024-12-30T16:37:19.726Z" }, - { url = "https://files.pythonhosted.org/packages/da/11/a9422850fd506edbcdc7f6090682ecceaf1f87b9dd847f9df79942da8506/mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", size = 11120432, upload-time = "2024-12-30T16:37:11.533Z" }, - { url = "https://files.pythonhosted.org/packages/b6/9e/47e450fd39078d9c02d620545b2cb37993a8a8bdf7db3652ace2f80521ca/mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", size = 10279515, upload-time = "2024-12-30T16:37:40.724Z" }, - { url = "https://files.pythonhosted.org/packages/01/b5/6c8d33bd0f851a7692a8bfe4ee75eb82b6983a3cf39e5e32a5d2a723f0c1/mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", size = 12025791, upload-time = "2024-12-30T16:36:58.73Z" }, - { url = "https://files.pythonhosted.org/packages/f0/4c/e10e2c46ea37cab5c471d0ddaaa9a434dc1d28650078ac1b56c2d7b9b2e4/mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", size = 12749203, upload-time = "2024-12-30T16:37:03.741Z" }, - { url = "https://files.pythonhosted.org/packages/88/55/beacb0c69beab2153a0f57671ec07861d27d735a0faff135a494cd4f5020/mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", size = 12885900, upload-time = "2024-12-30T16:37:57.948Z" }, - { url = "https://files.pythonhosted.org/packages/a2/75/8c93ff7f315c4d086a2dfcde02f713004357d70a163eddb6c56a6a5eff40/mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", size = 9777869, upload-time = "2024-12-30T16:37:33.428Z" }, - { url = "https://files.pythonhosted.org/packages/43/1b/b38c079609bb4627905b74fc6a49849835acf68547ac33d8ceb707de5f52/mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", size = 11266668, upload-time = "2024-12-30T16:38:02.211Z" }, - { url = "https://files.pythonhosted.org/packages/6b/75/2ed0d2964c1ffc9971c729f7a544e9cd34b2cdabbe2d11afd148d7838aa2/mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", size = 10254060, upload-time = "2024-12-30T16:37:46.131Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5f/7b8051552d4da3c51bbe8fcafffd76a6823779101a2b198d80886cd8f08e/mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", size = 11933167, upload-time = "2024-12-30T16:37:43.534Z" }, - { url = "https://files.pythonhosted.org/packages/04/90/f53971d3ac39d8b68bbaab9a4c6c58c8caa4d5fd3d587d16f5927eeeabe1/mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", size = 12864341, upload-time = "2024-12-30T16:37:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/03/d2/8bc0aeaaf2e88c977db41583559319f1821c069e943ada2701e86d0430b7/mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89", size = 12972991, upload-time = "2024-12-30T16:37:06.743Z" }, - { url = "https://files.pythonhosted.org/packages/6f/17/07815114b903b49b0f2cf7499f1c130e5aa459411596668267535fe9243c/mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", size = 9879016, upload-time = "2024-12-30T16:37:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9e/15/bb6a686901f59222275ab228453de741185f9d54fecbaacec041679496c6/mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", size = 11252097, upload-time = "2024-12-30T16:37:25.144Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b3/8b0f74dfd072c802b7fa368829defdf3ee1566ba74c32a2cb2403f68024c/mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", size = 10239728, upload-time = "2024-12-30T16:38:08.634Z" }, - { url = "https://files.pythonhosted.org/packages/c5/9b/4fd95ab20c52bb5b8c03cc49169be5905d931de17edfe4d9d2986800b52e/mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", size = 11924965, upload-time = "2024-12-30T16:38:12.132Z" }, - { url = "https://files.pythonhosted.org/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", size = 12867660, upload-time = "2024-12-30T16:38:17.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", size = 12969198, upload-time = "2024-12-30T16:38:32.839Z" }, - { url = "https://files.pythonhosted.org/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", size = 9885276, upload-time = "2024-12-30T16:38:20.828Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1f/186d133ae2514633f8558e78cd658070ba686c0e9275c5a5c24a1e1f0d67/mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35", size = 11200493, upload-time = "2024-12-30T16:38:26.935Z" }, - { url = "https://files.pythonhosted.org/packages/af/fc/4842485d034e38a4646cccd1369f6b1ccd7bc86989c52770d75d719a9941/mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc", size = 10357702, upload-time = "2024-12-30T16:38:50.623Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e6/457b83f2d701e23869cfec013a48a12638f75b9d37612a9ddf99072c1051/mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9", size = 12091104, upload-time = "2024-12-30T16:38:53.735Z" }, - { url = "https://files.pythonhosted.org/packages/f1/bf/76a569158db678fee59f4fd30b8e7a0d75bcbaeef49edd882a0d63af6d66/mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb", size = 12830167, upload-time = "2024-12-30T16:38:56.437Z" }, - { url = "https://files.pythonhosted.org/packages/43/bc/0bc6b694b3103de9fed61867f1c8bd33336b913d16831431e7cb48ef1c92/mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60", size = 13013834, upload-time = "2024-12-30T16:38:59.204Z" }, - { url = "https://files.pythonhosted.org/packages/b0/79/5f5ec47849b6df1e6943d5fd8e6632fbfc04b4fd4acfa5a5a9535d11b4e2/mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c", size = 9781231, upload-time = "2024-12-30T16:39:05.124Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b5/32dd67b69a16d088e533962e5044e51004176a9952419de0370cdaead0f8/mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", size = 2752905, upload-time = "2024-12-30T16:38:42.021Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/a9/3d7aa83955617cdf02f94e50aab5c830d205cfa4320cf124ff64acce3a8e/mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972", size = 11003299, upload-time = "2025-07-31T07:54:06.425Z" }, + { url = "https://files.pythonhosted.org/packages/83/e8/72e62ff837dd5caaac2b4a5c07ce769c8e808a00a65e5d8f94ea9c6f20ab/mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7", size = 10125451, upload-time = "2025-07-31T07:53:52.974Z" }, + { url = "https://files.pythonhosted.org/packages/7d/10/f3f3543f6448db11881776f26a0ed079865926b0c841818ee22de2c6bbab/mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df", size = 11916211, upload-time = "2025-07-31T07:53:18.879Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/63e83ed551282d67bb3f7fea2cd5561b08d2bb6eb287c096539feb5ddbc5/mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390", size = 12652687, upload-time = "2025-07-31T07:53:30.544Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/68f2eeef11facf597143e85b694a161868b3b006a5fbad50e09ea117ef24/mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94", size = 12896322, upload-time = "2025-07-31T07:53:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/a3/87/8e3e9c2c8bd0d7e071a89c71be28ad088aaecbadf0454f46a540bda7bca6/mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b", size = 9507962, upload-time = "2025-07-31T07:53:08.431Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/eadc80c4e0a70db1c08921dcc220357ba8ab2faecb4392e3cebeb10edbfa/mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58", size = 10921009, upload-time = "2025-07-31T07:53:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c1/c869d8c067829ad30d9bdae051046561552516cfb3a14f7f0347b7d973ee/mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5", size = 10047482, upload-time = "2025-07-31T07:53:26.151Z" }, + { url = "https://files.pythonhosted.org/packages/98/b9/803672bab3fe03cee2e14786ca056efda4bb511ea02dadcedde6176d06d0/mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd", size = 11832883, upload-time = "2025-07-31T07:53:47.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/fcdac695beca66800918c18697b48833a9a6701de288452b6715a98cfee1/mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b", size = 12566215, upload-time = "2025-07-31T07:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/a932da3d3dace99ee8eb2043b6ab03b6768c36eb29a02f98f46c18c0da0e/mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5", size = 12751956, upload-time = "2025-07-31T07:53:36.263Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/6438a429e0f2f5cab8bc83e53dbebfa666476f40ee322e13cac5e64b79e7/mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b", size = 9507307, upload-time = "2025-07-31T07:53:59.734Z" }, + { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" }, + { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" }, + { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" }, + { url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037, upload-time = "2025-07-31T07:54:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550, upload-time = "2025-07-31T07:53:41.307Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963, upload-time = "2025-07-31T07:53:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189, upload-time = "2025-07-31T07:54:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322, upload-time = "2025-07-31T07:53:10.551Z" }, + { url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879, upload-time = "2025-07-31T07:52:56.683Z" }, + { url = "https://files.pythonhosted.org/packages/29/cb/673e3d34e5d8de60b3a61f44f80150a738bff568cd6b7efb55742a605e98/mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9", size = 10992466, upload-time = "2025-07-31T07:53:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d0/fe1895836eea3a33ab801561987a10569df92f2d3d4715abf2cfeaa29cb2/mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99", size = 10117638, upload-time = "2025-07-31T07:53:34.256Z" }, + { url = "https://files.pythonhosted.org/packages/97/f3/514aa5532303aafb95b9ca400a31054a2bd9489de166558c2baaeea9c522/mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8", size = 11915673, upload-time = "2025-07-31T07:52:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c3/c0805f0edec96fe8e2c048b03769a6291523d509be8ee7f56ae922fa3882/mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8", size = 12649022, upload-time = "2025-07-31T07:53:45.92Z" }, + { url = "https://files.pythonhosted.org/packages/45/3e/d646b5a298ada21a8512fa7e5531f664535a495efa672601702398cea2b4/mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259", size = 12895536, upload-time = "2025-07-31T07:53:06.17Z" }, + { url = "https://files.pythonhosted.org/packages/14/55/e13d0dcd276975927d1f4e9e2ec4fd409e199f01bdc671717e673cc63a22/mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d", size = 9512564, upload-time = "2025-07-31T07:53:12.346Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, ] [[package]] @@ -1116,6 +1123,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "pip" version = "25.2" @@ -1315,7 +1331,7 @@ provides-extras = ["aws", "docs", "encryption", "gssapi", "ocsp", "snappy", "tes [package.metadata.requires-dev] coverage = [ - { name = "coverage", specifier = ">=5,<=7.5" }, + { name = "coverage", specifier = ">=5,<=7.10.6" }, { name = "pytest-cov" }, ] dev = [{ name = "pre-commit", specifier = ">=4.0" }] @@ -1329,9 +1345,9 @@ perf = [{ name = "simplejson" }] pip = [{ name = "pip" }] pymongocrypt-source = [{ name = "pymongocrypt", git = "https://github.com/mongodb/libmongocrypt?subdirectory=bindings%2Fpython&rev=master" }] typing = [ - { name = "mypy", specifier = "==1.14.1" }, + { name = "mypy", specifier = "==1.17.1" }, { name = "pip" }, - { name = "pyright", specifier = "==1.1.392.post0" }, + { name = "pyright", specifier = "==1.1.405" }, { name = "typing-extensions" }, ] @@ -1375,15 +1391,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.392.post0" +version = "1.1.405" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/df/3c6f6b08fba7ccf49b114dfc4bb33e25c299883fd763f93fad47ef8bc58d/pyright-1.1.392.post0.tar.gz", hash = "sha256:3b7f88de74a28dcfa90c7d90c782b6569a48c2be5f9d4add38472bdaac247ebd", size = 3789911, upload-time = "2025-01-15T15:01:20.913Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/ba4bbee22e76af700ea593a1d8701e3225080956753bee9750dcc25e2649/pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763", size = 4068319, upload-time = "2025-09-04T03:37:06.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/b1/a18de17f40e4f61ca58856b9ef9b0febf74ff88978c3f7776f910071f567/pyright-1.1.392.post0-py3-none-any.whl", hash = "sha256:252f84458a46fa2f0fd4e2f91fc74f50b9ca52c757062e93f6c250c0d8329eb2", size = 5595487, upload-time = "2025-01-15T15:01:17.775Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/524f832e1ff1962a22a1accc775ca7b143ba2e9f5924bb6749dce566784a/pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a", size = 5905038, upload-time = "2025-09-04T03:37:04.913Z" }, ] [[package]] From e0b96544fb1fa33ccd1cb09d86b05d16a6e0f9d3 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 16 Sep 2025 10:54:58 -0500 Subject: [PATCH 06/20] PYTHON-5544 [v4.15] Revert changes to base protocol layer (#2538) --- doc/changelog.rst | 2 +- pymongo/asynchronous/encryption.py | 28 +- pymongo/asynchronous/pool.py | 161 +++++----- pymongo/network_layer.py | 468 +++++++++++++++++------------ pymongo/pool_shared.py | 127 +++++++- pymongo/synchronous/encryption.py | 28 +- pymongo/synchronous/pool.py | 161 +++++----- tools/synchro.py | 2 +- 8 files changed, 577 insertions(+), 400 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index c8e17bc30b..082c22fafc 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,7 +1,7 @@ Changelog ========= -Changes in Version 4.15.1 (2025/09/11) +Changes in Version 4.15.1 (2025/09/16) -------------------------------------- Version 4.15.1 is a bug fix release. diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index 2b1895b832..d32a5b3204 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -64,7 +64,6 @@ from pymongo.asynchronous.cursor import AsyncCursor from pymongo.asynchronous.database import AsyncDatabase from pymongo.asynchronous.mongo_client import AsyncMongoClient -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, TextOpts @@ -77,11 +76,11 @@ ServerSelectionTimeoutError, ) from pymongo.helpers_shared import _get_timeout_details -from pymongo.network_layer import PyMongoKMSProtocol, async_receive_kms, async_sendall +from pymongo.network_layer import async_socket_sendall from pymongo.operations import UpdateOne from pymongo.pool_options import PoolOptions from pymongo.pool_shared import ( - _configured_protocol_interface, + _async_configured_socket, _raise_connection_failure, ) from pymongo.read_concern import ReadConcern @@ -94,8 +93,10 @@ if TYPE_CHECKING: from pymongocrypt.mongocrypt import MongoCryptKmsContext + from pymongo.pyopenssl_context import _sslConn from pymongo.typings import _Address + _IS_SYNC = False _HTTPS_PORT = 443 @@ -110,10 +111,9 @@ _KEY_VAULT_OPTS = CodecOptions(document_class=RawBSONDocument) -async def _connect_kms(address: _Address, opts: PoolOptions) -> AsyncBaseConnection: +async def _connect_kms(address: _Address, opts: PoolOptions) -> Union[socket.socket, _sslConn]: try: - interface = await _configured_protocol_interface(address, opts, PyMongoKMSProtocol) - return AsyncBaseConnection(interface, opts) + return await _async_configured_socket(address, opts) except Exception as exc: _raise_connection_failure(address, exc, timeout_details=_get_timeout_details(opts)) @@ -198,11 +198,19 @@ async def kms_request(self, kms_context: MongoCryptKmsContext) -> None: try: conn = await _connect_kms(address, opts) try: - await async_sendall(conn.conn.get_conn, message) + await async_socket_sendall(conn, message) while kms_context.bytes_needed > 0: # CSOT: update timeout. - conn.set_conn_timeout(max(_csot.clamp_remaining(_KMS_CONNECT_TIMEOUT), 0)) - data = await async_receive_kms(conn, kms_context.bytes_needed) + conn.settimeout(max(_csot.clamp_remaining(_KMS_CONNECT_TIMEOUT), 0)) + data: memoryview | bytes + if _IS_SYNC: + data = conn.recv(kms_context.bytes_needed) + else: + from pymongo.network_layer import ( # type: ignore[attr-defined] + async_receive_data_socket, + ) + + data = await async_receive_data_socket(conn, kms_context.bytes_needed) if not data: raise OSError("KMS connection closed") kms_context.feed(data) @@ -221,7 +229,7 @@ async def kms_request(self, kms_context: MongoCryptKmsContext) -> None: address, exc, msg_prefix=msg_prefix, timeout_details=_get_timeout_details(opts) ) finally: - await conn.close_conn(None) + conn.close() except MongoCryptError: raise # Propagate MongoCryptError errors directly. except Exception as exc: diff --git a/pymongo/asynchronous/pool.py b/pymongo/asynchronous/pool.py index 8c169b4c52..196ec9040f 100644 --- a/pymongo/asynchronous/pool.py +++ b/pymongo/asynchronous/pool.py @@ -123,89 +123,7 @@ def _set_non_inheritable_non_atomic(fd: int) -> None: # noqa: ARG001 _IS_SYNC = False -class AsyncBaseConnection: - """A base connection object for server and kms connections.""" - - def __init__(self, conn: AsyncNetworkingInterface, opts: PoolOptions): - self.conn = conn - self.socket_checker: SocketChecker = SocketChecker() - self.cancel_context: _CancellationContext = _CancellationContext() - self.is_sdam = False - self.closed = False - self.last_timeout: float | None = None - self.more_to_come = False - self.opts = opts - self.max_wire_version = -1 - - def set_conn_timeout(self, timeout: Optional[float]) -> None: - """Cache last timeout to avoid duplicate calls to conn.settimeout.""" - if timeout == self.last_timeout: - return - self.last_timeout = timeout - self.conn.get_conn.settimeout(timeout) - - def apply_timeout( - self, client: AsyncMongoClient[Any], cmd: Optional[MutableMapping[str, Any]] - ) -> Optional[float]: - # CSOT: use remaining timeout when set. - timeout = _csot.remaining() - if timeout is None: - # Reset the socket timeout unless we're performing a streaming monitor check. - if not self.more_to_come: - self.set_conn_timeout(self.opts.socket_timeout) - return None - # RTT validation. - rtt = _csot.get_rtt() - if rtt is None: - rtt = self.connect_rtt - max_time_ms = timeout - rtt - if max_time_ms < 0: - timeout_details = _get_timeout_details(self.opts) - formatted = format_timeout_details(timeout_details) - # CSOT: raise an error without running the command since we know it will time out. - errmsg = f"operation would exceed time limit, remaining timeout:{timeout:.5f} <= network round trip time:{rtt:.5f} {formatted}" - if self.max_wire_version != -1: - raise ExecutionTimeout( - errmsg, - 50, - {"ok": 0, "errmsg": errmsg, "code": 50}, - self.max_wire_version, - ) - else: - raise TimeoutError(errmsg) - if cmd is not None: - cmd["maxTimeMS"] = int(max_time_ms * 1000) - self.set_conn_timeout(timeout) - return timeout - - async def close_conn(self, reason: Optional[str]) -> None: - """Close this connection with a reason.""" - if self.closed: - return - await self._close_conn() - - async def _close_conn(self) -> None: - """Close this connection.""" - if self.closed: - return - self.closed = True - self.cancel_context.cancel() - # Note: We catch exceptions to avoid spurious errors on interpreter - # shutdown. - try: - await self.conn.close() - except Exception: # noqa: S110 - pass - - def conn_closed(self) -> bool: - """Return True if we know socket has been closed, False otherwise.""" - if _IS_SYNC: - return self.socket_checker.socket_closed(self.conn.get_conn) - else: - return self.conn.is_closing() - - -class AsyncConnection(AsyncBaseConnection): +class AsyncConnection: """Store a connection with some metadata. :param conn: a raw connection object @@ -223,27 +141,29 @@ def __init__( id: int, is_sdam: bool, ): - super().__init__(conn, pool.opts) self.pool_ref = weakref.ref(pool) - self.address: tuple[str, int] = address - self.id: int = id + self.conn = conn + self.address = address + self.id = id self.is_sdam = is_sdam + self.closed = False self.last_checkin_time = time.monotonic() self.performed_handshake = False self.is_writable: bool = False self.max_wire_version = MAX_WIRE_VERSION - self.max_bson_size: int = MAX_BSON_SIZE - self.max_message_size: int = MAX_MESSAGE_SIZE - self.max_write_batch_size: int = MAX_WRITE_BATCH_SIZE + self.max_bson_size = MAX_BSON_SIZE + self.max_message_size = MAX_MESSAGE_SIZE + self.max_write_batch_size = MAX_WRITE_BATCH_SIZE self.supports_sessions = False self.hello_ok: bool = False - self.is_mongos: bool = False + self.is_mongos = False self.op_msg_enabled = False self.listeners = pool.opts._event_listeners self.enabled_for_cmap = pool.enabled_for_cmap self.enabled_for_logging = pool.enabled_for_logging self.compression_settings = pool.opts._compression_settings self.compression_context: Union[SnappyContext, ZlibContext, ZstdContext, None] = None + self.socket_checker: SocketChecker = SocketChecker() self.oidc_token_gen_id: Optional[int] = None # Support for mechanism negotiation on the initial handshake. self.negotiated_mechs: Optional[list[str]] = None @@ -254,6 +174,9 @@ def __init__( self.pool_gen = pool.gen self.generation = self.pool_gen.get_overall() self.ready = False + self.cancel_context: _CancellationContext = _CancellationContext() + self.opts = pool.opts + self.more_to_come: bool = False # For load balancer support. self.service_id: Optional[ObjectId] = None self.server_connection_id: Optional[int] = None @@ -269,6 +192,44 @@ def __init__( # For gossiping $clusterTime from the connection handshake to the client. self._cluster_time = None + def set_conn_timeout(self, timeout: Optional[float]) -> None: + """Cache last timeout to avoid duplicate calls to conn.settimeout.""" + if timeout == self.last_timeout: + return + self.last_timeout = timeout + self.conn.get_conn.settimeout(timeout) + + def apply_timeout( + self, client: AsyncMongoClient[Any], cmd: Optional[MutableMapping[str, Any]] + ) -> Optional[float]: + # CSOT: use remaining timeout when set. + timeout = _csot.remaining() + if timeout is None: + # Reset the socket timeout unless we're performing a streaming monitor check. + if not self.more_to_come: + self.set_conn_timeout(self.opts.socket_timeout) + return None + # RTT validation. + rtt = _csot.get_rtt() + if rtt is None: + rtt = self.connect_rtt + max_time_ms = timeout - rtt + if max_time_ms < 0: + timeout_details = _get_timeout_details(self.opts) + formatted = format_timeout_details(timeout_details) + # CSOT: raise an error without running the command since we know it will time out. + errmsg = f"operation would exceed time limit, remaining timeout:{timeout:.5f} <= network round trip time:{rtt:.5f} {formatted}" + raise ExecutionTimeout( + errmsg, + 50, + {"ok": 0, "errmsg": errmsg, "code": 50}, + self.max_wire_version, + ) + if cmd is not None: + cmd["maxTimeMS"] = int(max_time_ms * 1000) + self.set_conn_timeout(timeout) + return timeout + def pin_txn(self) -> None: self.pinned_txn = True assert not self.pinned_cursor @@ -612,6 +573,26 @@ async def close_conn(self, reason: Optional[str]) -> None: error=reason, ) + async def _close_conn(self) -> None: + """Close this connection.""" + if self.closed: + return + self.closed = True + self.cancel_context.cancel() + # Note: We catch exceptions to avoid spurious errors on interpreter + # shutdown. + try: + await self.conn.close() + except Exception: # noqa: S110 + pass + + def conn_closed(self) -> bool: + """Return True if we know socket has been closed, False otherwise.""" + if _IS_SYNC: + return self.socket_checker.socket_closed(self.conn.get_conn) + else: + return self.conn.is_closing() + def send_cluster_time( self, command: MutableMapping[str, Any], diff --git a/pymongo/network_layer.py b/pymongo/network_layer.py index a3900e30c1..2e5b61f8ae 100644 --- a/pymongo/network_layer.py +++ b/pymongo/network_layer.py @@ -22,11 +22,10 @@ import struct import sys import time -from asyncio import BaseProtocol, BaseTransport, BufferedProtocol, Future, Transport +from asyncio import AbstractEventLoop, BaseTransport, BufferedProtocol, Future, Transport from typing import ( TYPE_CHECKING, Any, - Callable, Optional, Union, ) @@ -39,30 +38,208 @@ from pymongo.message import _UNPACK_REPLY, _OpMsg, _OpReply from pymongo.socket_checker import _errno_from_exception -if TYPE_CHECKING: - from pymongo.asynchronous.pool import AsyncBaseConnection, AsyncConnection +try: + from ssl import SSLError, SSLSocket + + _HAVE_SSL = True +except ImportError: + _HAVE_SSL = False + +try: from pymongo.pyopenssl_context import _sslConn - from pymongo.synchronous.pool import BaseConnection, Connection + + _HAVE_PYOPENSSL = True +except ImportError: + _HAVE_PYOPENSSL = False + _sslConn = SSLSocket # type: ignore[assignment, misc] + +from pymongo.ssl_support import ( + BLOCKING_IO_LOOKUP_ERROR, + BLOCKING_IO_READ_ERROR, + BLOCKING_IO_WRITE_ERROR, +) + +if TYPE_CHECKING: + from pymongo.asynchronous.pool import AsyncConnection + from pymongo.synchronous.pool import Connection _UNPACK_HEADER = struct.Struct(" None: + timeout = sock.gettimeout() + sock.settimeout(0.0) + loop = asyncio.get_running_loop() + try: + if _HAVE_SSL and isinstance(sock, (SSLSocket, _sslConn)): + await asyncio.wait_for(_async_socket_sendall_ssl(sock, buf, loop), timeout=timeout) + else: + await asyncio.wait_for(loop.sock_sendall(sock, buf), timeout=timeout) # type: ignore[arg-type] + except asyncio.TimeoutError as exc: + # Convert the asyncio.wait_for timeout error to socket.timeout which pool.py understands. + raise socket.timeout("timed out") from exc + finally: + sock.settimeout(timeout) + + +if sys.platform != "win32": + + async def _async_socket_sendall_ssl( + sock: Union[socket.socket, _sslConn], buf: bytes, loop: AbstractEventLoop + ) -> None: + view = memoryview(buf) + sent = 0 + + def _is_ready(fut: Future[Any]) -> None: + if fut.done(): + return + fut.set_result(None) + + while sent < len(buf): + try: + sent += sock.send(view[sent:]) # type:ignore[arg-type] + except BLOCKING_IO_ERRORS as exc: + fd = sock.fileno() + # Check for closed socket. + if fd == -1: + raise SSLError("Underlying socket has been closed") from None + if isinstance(exc, BLOCKING_IO_READ_ERROR): + fut = loop.create_future() + loop.add_reader(fd, _is_ready, fut) + try: + await fut + finally: + loop.remove_reader(fd) + if isinstance(exc, BLOCKING_IO_WRITE_ERROR): + fut = loop.create_future() + loop.add_writer(fd, _is_ready, fut) + try: + await fut + finally: + loop.remove_writer(fd) + if _HAVE_PYOPENSSL and isinstance(exc, BLOCKING_IO_LOOKUP_ERROR): + fut = loop.create_future() + loop.add_reader(fd, _is_ready, fut) + try: + loop.add_writer(fd, _is_ready, fut) + await fut + finally: + loop.remove_reader(fd) + loop.remove_writer(fd) + + async def _async_socket_receive_ssl( + conn: _sslConn, length: int, loop: AbstractEventLoop, once: Optional[bool] = False + ) -> memoryview: + mv = memoryview(bytearray(length)) + total_read = 0 + + def _is_ready(fut: Future[Any]) -> None: + if fut.done(): + return + fut.set_result(None) + + while total_read < length: + try: + read = conn.recv_into(mv[total_read:]) + if read == 0: + raise OSError("connection closed") + # KMS responses update their expected size after the first batch, stop reading after one loop + if once: + return mv[:read] + total_read += read + except BLOCKING_IO_ERRORS as exc: + fd = conn.fileno() + # Check for closed socket. + if fd == -1: + raise SSLError("Underlying socket has been closed") from None + if isinstance(exc, BLOCKING_IO_READ_ERROR): + fut = loop.create_future() + loop.add_reader(fd, _is_ready, fut) + try: + await fut + finally: + loop.remove_reader(fd) + if isinstance(exc, BLOCKING_IO_WRITE_ERROR): + fut = loop.create_future() + loop.add_writer(fd, _is_ready, fut) + try: + await fut + finally: + loop.remove_writer(fd) + if _HAVE_PYOPENSSL and isinstance(exc, BLOCKING_IO_LOOKUP_ERROR): + fut = loop.create_future() + loop.add_reader(fd, _is_ready, fut) + try: + loop.add_writer(fd, _is_ready, fut) + await fut + finally: + loop.remove_reader(fd) + loop.remove_writer(fd) + return mv + +else: + # The default Windows asyncio event loop does not support loop.add_reader/add_writer: + # https://docs.python.org/3/library/asyncio-platforms.html#asyncio-platform-support + # Note: In PYTHON-4493 we plan to replace this code with asyncio streams. + async def _async_socket_sendall_ssl( + sock: Union[socket.socket, _sslConn], buf: bytes, dummy: AbstractEventLoop + ) -> None: + view = memoryview(buf) + total_length = len(buf) + total_sent = 0 + # Backoff starts at 1ms, doubles on timeout up to 512ms, and halves on success + # down to 1ms. + backoff = 0.001 + while total_sent < total_length: + try: + sent = sock.send(view[total_sent:]) + except BLOCKING_IO_ERRORS: + await asyncio.sleep(backoff) + sent = 0 + if sent > 0: + backoff = max(backoff / 2, 0.001) + else: + backoff = min(backoff * 2, 0.512) + total_sent += sent + + async def _async_socket_receive_ssl( + conn: _sslConn, length: int, dummy: AbstractEventLoop, once: Optional[bool] = False + ) -> memoryview: + mv = memoryview(bytearray(length)) + total_read = 0 + # Backoff starts at 1ms, doubles on timeout up to 512ms, and halves on success + # down to 1ms. + backoff = 0.001 + while total_read < length: + try: + read = conn.recv_into(mv[total_read:]) + if read == 0: + raise OSError("connection closed") + # KMS responses update their expected size after the first batch, stop reading after one loop + if once: + return mv[:read] + except BLOCKING_IO_ERRORS: + await asyncio.sleep(backoff) + read = 0 + if read > 0: + backoff = max(backoff / 2, 0.001) + else: + backoff = min(backoff * 2, 0.512) + total_read += read + return mv def sendall(sock: Union[socket.socket, _sslConn], buf: bytes) -> None: sock.sendall(buf) -async def _poll_cancellation(conn: AsyncBaseConnection) -> None: +async def _poll_cancellation(conn: AsyncConnection) -> None: while True: if conn.cancel_context.cancelled: return @@ -70,7 +247,49 @@ async def _poll_cancellation(conn: AsyncBaseConnection) -> None: await asyncio.sleep(_POLL_TIMEOUT) -def wait_for_read(conn: BaseConnection, deadline: Optional[float]) -> None: +async def async_receive_data_socket( + sock: Union[socket.socket, _sslConn], length: int +) -> memoryview: + sock_timeout = sock.gettimeout() + timeout = sock_timeout + + sock.settimeout(0.0) + loop = asyncio.get_running_loop() + try: + if _HAVE_SSL and isinstance(sock, (SSLSocket, _sslConn)): + return await asyncio.wait_for( + _async_socket_receive_ssl(sock, length, loop, once=True), # type: ignore[arg-type] + timeout=timeout, + ) + else: + return await asyncio.wait_for( + _async_socket_receive(sock, length, loop), # type: ignore[arg-type] + timeout=timeout, + ) + except asyncio.TimeoutError as err: + raise socket.timeout("timed out") from err + finally: + sock.settimeout(sock_timeout) + + +async def _async_socket_receive( + conn: socket.socket, length: int, loop: AbstractEventLoop +) -> memoryview: + mv = memoryview(bytearray(length)) + bytes_read = 0 + while bytes_read < length: + chunk_length = await loop.sock_recv_into(conn, mv[bytes_read:]) + if chunk_length == 0: + raise OSError("connection closed") + bytes_read += chunk_length + return mv + + +_PYPY = "PyPy" in sys.version +_WINDOWS = sys.platform == "win32" + + +def wait_for_read(conn: Connection, deadline: Optional[float]) -> None: """Block until at least one byte is read, or a timeout, or a cancel.""" sock = conn.conn.sock timed_out = False @@ -103,7 +322,7 @@ def wait_for_read(conn: BaseConnection, deadline: Optional[float]) -> None: raise socket.timeout("timed out") -def receive_data(conn: BaseConnection, length: int, deadline: Optional[float]) -> memoryview: +def receive_data(conn: Connection, length: int, deadline: Optional[float]) -> memoryview: buf = bytearray(length) mv = memoryview(buf) bytes_read = 0 @@ -193,7 +412,7 @@ def sock(self) -> Any: class AsyncNetworkingInterface(NetworkingInterfaceBase): - def __init__(self, conn: tuple[Transport, PyMongoBaseProtocol]): + def __init__(self, conn: tuple[Transport, PyMongoProtocol]): super().__init__(conn) @property @@ -211,7 +430,7 @@ def is_closing(self) -> bool: return self.conn[0].is_closing() @property - def get_conn(self) -> PyMongoBaseProtocol: + def get_conn(self) -> PyMongoProtocol: return self.conn[1] @property @@ -250,51 +469,9 @@ def recv_into(self, buffer: bytes | memoryview) -> int: return self.conn.recv_into(buffer) -class PyMongoBaseProtocol(BaseProtocol): +class PyMongoProtocol(BufferedProtocol): def __init__(self, timeout: Optional[float] = None): self.transport: Transport = None # type: ignore[assignment] - self._timeout = timeout - self._closed = asyncio.get_running_loop().create_future() - self._connection_lost = False - - def settimeout(self, timeout: float | None) -> None: - self._timeout = timeout - - @property - def gettimeout(self) -> float | None: - """The configured timeout for the socket that underlies our protocol pair.""" - return self._timeout - - def close(self, exc: Optional[Exception] = None) -> None: - self.transport.abort() - self._resolve_pending(exc) - self._connection_lost = True - - def connection_lost(self, exc: Optional[Exception] = None) -> None: - self._resolve_pending(exc) - if not self._closed.done(): - self._closed.set_result(None) - - def _resolve_pending(self, exc: Optional[Exception] = None) -> None: - pass - - async def wait_closed(self) -> None: - await self._closed - - async def write(self, message: bytes) -> None: - """Write a message to this connection's transport.""" - if self.transport.is_closing(): - raise OSError("Connection is closed") - self.transport.write(message) - self.transport.resume_reading() - - async def read(self, *args: Any) -> Any: - raise NotImplementedError - - -class PyMongoProtocol(PyMongoBaseProtocol, BufferedProtocol): - def __init__(self, timeout: Optional[float] = None): - super().__init__(timeout) # Each message is reader in 2-3 parts: header, compression header, and message body # The message buffer is allocated after the header is read. self._header = memoryview(bytearray(16)) @@ -308,14 +485,25 @@ def __init__(self, timeout: Optional[float] = None): self._expecting_compression = False self._message_size = 0 self._op_code = 0 + self._connection_lost = False self._read_waiter: Optional[Future[Any]] = None + self._timeout = timeout self._is_compressed = False self._compressor_id: Optional[int] = None self._max_message_size = MAX_MESSAGE_SIZE self._response_to: Optional[int] = None + self._closed = asyncio.get_running_loop().create_future() self._pending_messages: collections.deque[Future[Any]] = collections.deque() self._done_messages: collections.deque[Future[Any]] = collections.deque() + def settimeout(self, timeout: float | None) -> None: + self._timeout = timeout + + @property + def gettimeout(self) -> float | None: + """The configured timeout for the socket that underlies our protocol pair.""" + return self._timeout + def connection_made(self, transport: BaseTransport) -> None: """Called exactly once when a connection is made. The transport argument is the transport representing the write side of the connection. @@ -323,6 +511,13 @@ def connection_made(self, transport: BaseTransport) -> None: self.transport = transport # type: ignore[assignment] self.transport.set_write_buffer_limits(MAX_MESSAGE_SIZE, MAX_MESSAGE_SIZE) + async def write(self, message: bytes) -> None: + """Write a message to this connection's transport.""" + if self.transport.is_closing(): + raise OSError("Connection is closed") + self.transport.write(message) + self.transport.resume_reading() + async def read(self, request_id: Optional[int], max_message_size: int) -> tuple[bytes, int]: """Read a single MongoDB Wire Protocol message from this connection.""" if self.transport: @@ -465,7 +660,7 @@ def process_compression_header(self) -> tuple[int, int]: op_code, _, compressor_id = _UNPACK_COMPRESSION_HEADER(self._compression_header) return op_code, compressor_id - def _resolve_pending(self, exc: Optional[Exception] = None) -> None: + def _resolve_pending_messages(self, exc: Optional[Exception] = None) -> None: pending = list(self._pending_messages) for msg in pending: if not msg.done(): @@ -475,92 +670,21 @@ def _resolve_pending(self, exc: Optional[Exception] = None) -> None: msg.set_exception(exc) self._done_messages.append(msg) + def close(self, exc: Optional[Exception] = None) -> None: + self.transport.abort() + self._resolve_pending_messages(exc) + self._connection_lost = True -class PyMongoKMSProtocol(PyMongoBaseProtocol): - def __init__(self, timeout: Optional[float] = None): - super().__init__(timeout) - self._buffers: collections.deque[memoryview[bytes]] = collections.deque() - self._bytes_ready = 0 - self._pending_reads: collections.deque[int] = collections.deque() - self._pending_listeners: collections.deque[Future[Any]] = collections.deque() - - def connection_made(self, transport: BaseTransport) -> None: - """Called exactly once when a connection is made. - The transport argument is the transport representing the write side of the connection. - """ - self.transport = transport # type: ignore[assignment] - - def data_received(self, data: bytes) -> None: - if self._connection_lost: - return - - self._bytes_ready += len(data) - self._buffers.append(memoryview(data)) - - if not len(self._pending_reads): - return + def connection_lost(self, exc: Optional[Exception] = None) -> None: + self._resolve_pending_messages(exc) + if not self._closed.done(): + self._closed.set_result(None) - bytes_needed = self._pending_reads.popleft() - data = self._read(bytes_needed) - waiter = self._pending_listeners.popleft() - waiter.set_result(data) - - async def read(self, bytes_needed: int) -> bytes: - """Read up to the requested bytes from this connection.""" - # Note: all reads are "up-to" bytes_needed because we don't know if the kms_context - # has processed a Content-Length header and is requesting a response or not. - # Wait for other listeners first. - if len(self._pending_listeners): - await asyncio.gather(*self._pending_listeners) - # If there are bytes ready, then there is no need to wait further. - if self._bytes_ready > 0: - return self._read(bytes_needed) - if self.transport: - try: - self.transport.resume_reading() - # Known bug in SSL Protocols, fixed in Python 3.11: https://github.com/python/cpython/issues/89322 - except AttributeError: - raise OSError("connection is already closed") from None - if self.transport and self.transport.is_closing(): - raise OSError("connection is already closed") - self._pending_reads.append(bytes_needed) - read_waiter = asyncio.get_running_loop().create_future() - self._pending_listeners.append(read_waiter) - return await read_waiter - - def _resolve_pending(self, exc: Optional[Exception] = None) -> None: - while self._pending_listeners: - fut = self._pending_listeners.popleft() - fut.set_result(b"") - - def _read(self, bytes_needed: int) -> bytes: - """Read bytes.""" - # Send the bytes to the listener. - if self._bytes_ready < bytes_needed: - bytes_needed = self._bytes_ready - self._bytes_ready -= bytes_needed - - output_buf = memoryview(bytearray(bytes_needed)) - n_remaining = bytes_needed - out_index = 0 - while n_remaining > 0: - buffer = self._buffers.popleft() - buf_size = len(buffer) - # if we didn't exhaust the buffer, read the partial data and return the buffer. - if buf_size > n_remaining: - output_buf[out_index : n_remaining + out_index] = buffer[:n_remaining] - buffer = buffer[n_remaining:] - n_remaining = 0 - self._buffers.appendleft(buffer) - # otherwise exhaust the buffer. - else: - output_buf[out_index : out_index + buf_size] = buffer[:] - out_index += buf_size - n_remaining -= buf_size - return bytes(output_buf) + async def wait_closed(self) -> None: + await self._closed -async def async_sendall(conn: PyMongoBaseProtocol, buf: bytes) -> None: +async def async_sendall(conn: PyMongoProtocol, buf: bytes) -> None: try: await asyncio.wait_for(conn.write(buf), timeout=conn.gettimeout) except asyncio.TimeoutError as exc: @@ -568,18 +692,12 @@ async def async_sendall(conn: PyMongoBaseProtocol, buf: bytes) -> None: raise socket.timeout("timed out") from exc -async def async_receive_kms(conn: AsyncBaseConnection, bytes_needed: int) -> bytes: - """Receive raw bytes from the kms connection.""" - - def callback(result: Any) -> bytes: - return result - - return await _async_receive_data(conn, callback, bytes_needed) - - -async def _async_receive_data( - conn: AsyncBaseConnection, callback: Callable[..., Any], *args: Any -) -> Any: +async def async_receive_message( + conn: AsyncConnection, + request_id: Optional[int], + max_message_size: int = MAX_MESSAGE_SIZE, +) -> Union[_OpReply, _OpMsg]: + """Receive a raw BSON message or raise socket.error.""" timeout: Optional[Union[float, int]] timeout = conn.conn.gettimeout if _csot.get_timeout(): @@ -595,8 +713,8 @@ async def _async_receive_data( # timeouts on AWS Lambda and other FaaS environments. timeout = max(deadline - time.monotonic(), 0) - read_task = create_task(conn.conn.get_conn.read(*args)) cancellation_task = create_task(_poll_cancellation(conn)) + read_task = create_task(conn.conn.get_conn.read(request_id, max_message_size)) tasks = [read_task, cancellation_task] try: done, pending = await asyncio.wait( @@ -609,7 +727,14 @@ async def _async_receive_data( if len(done) == 0: raise socket.timeout("timed out") if read_task in done: - return callback(read_task.result()) + data, op_code = read_task.result() + try: + unpack_reply = _UNPACK_REPLY[op_code] + except KeyError: + raise ProtocolError( + f"Got opcode {op_code!r} but expected {_UNPACK_REPLY.keys()!r}" + ) from None + return unpack_reply(data) raise _OperationCancelled("operation cancelled") except asyncio.CancelledError: for task in tasks: @@ -618,31 +743,6 @@ async def _async_receive_data( raise -async def async_receive_message( - conn: AsyncConnection, - request_id: Optional[int], - max_message_size: int = MAX_MESSAGE_SIZE, -) -> Union[_OpReply, _OpMsg]: - """Receive a raw BSON message or raise socket.error.""" - - def callback(result: Any) -> _OpMsg | _OpReply: - data, op_code = result - try: - unpack_reply = _UNPACK_REPLY[op_code] - except KeyError: - raise ProtocolError( - f"Got opcode {op_code!r} but expected {_UNPACK_REPLY.keys()!r}" - ) from None - return unpack_reply(data) - - return await _async_receive_data(conn, callback, request_id, max_message_size) - - -def receive_kms(conn: BaseConnection, bytes_needed: int) -> bytes: - """Receive raw bytes from the kms connection.""" - return conn.conn.sock.recv(bytes_needed) - - def receive_message( conn: Connection, request_id: Optional[int], max_message_size: int = MAX_MESSAGE_SIZE ) -> Union[_OpReply, _OpMsg]: @@ -670,7 +770,7 @@ def receive_message( f"Message length ({length!r}) is larger than server max " f"message size ({max_message_size!r})" ) - data: bytes | memoryview + data: memoryview | bytes if op_code == 2012: op_code, _, compressor_id = _UNPACK_COMPRESSION_HEADER(receive_data(conn, 9, deadline)) data = decompress(receive_data(conn, length - 25, deadline), compressor_id) diff --git a/pymongo/pool_shared.py b/pymongo/pool_shared.py index 0536dc3835..ac562af542 100644 --- a/pymongo/pool_shared.py +++ b/pymongo/pool_shared.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio +import functools import socket import ssl import sys @@ -24,6 +25,7 @@ Any, NoReturn, Optional, + Union, ) from pymongo import _csot @@ -35,17 +37,13 @@ _CertificateError, ) from pymongo.helpers_shared import _get_timeout_details, format_timeout_details -from pymongo.network_layer import ( - AsyncNetworkingInterface, - NetworkingInterface, - PyMongoBaseProtocol, - PyMongoProtocol, -) +from pymongo.network_layer import AsyncNetworkingInterface, NetworkingInterface, PyMongoProtocol from pymongo.pool_options import PoolOptions from pymongo.ssl_support import PYSSLError, SSLError, _has_sni SSLErrors = (PYSSLError, SSLError) if TYPE_CHECKING: + from pymongo.pyopenssl_context import _sslConn from pymongo.typings import _Address try: @@ -246,10 +244,64 @@ async def _async_create_connection(address: _Address, options: PoolOptions) -> s raise OSError("getaddrinfo failed") +async def _async_configured_socket( + address: _Address, options: PoolOptions +) -> Union[socket.socket, _sslConn]: + """Given (host, port) and PoolOptions, return a raw configured socket. + + Can raise socket.error, ConnectionFailure, or _CertificateError. + + Sets socket's SSL and timeout options. + """ + sock = await _async_create_connection(address, options) + ssl_context = options._ssl_context + + if ssl_context is None: + sock.settimeout(options.socket_timeout) + return sock + + host = address[0] + try: + # We have to pass hostname / ip address to wrap_socket + # to use SSLContext.check_hostname. + if _has_sni(False): + loop = asyncio.get_running_loop() + ssl_sock = await loop.run_in_executor( + None, + functools.partial(ssl_context.wrap_socket, sock, server_hostname=host), # type: ignore[assignment, misc, unused-ignore] + ) + else: + loop = asyncio.get_running_loop() + ssl_sock = await loop.run_in_executor(None, ssl_context.wrap_socket, sock) # type: ignore[assignment, misc, unused-ignore] + except _CertificateError: + sock.close() + # Raise _CertificateError directly like we do after match_hostname + # below. + raise + except (OSError, *SSLErrors) as exc: + sock.close() + # We raise AutoReconnect for transient and permanent SSL handshake + # failures alike. Permanent handshake failures, like protocol + # mismatch, will be turned into ServerSelectionTimeoutErrors later. + details = _get_timeout_details(options) + _raise_connection_failure(address, exc, "SSL handshake failed: ", timeout_details=details) + if ( + ssl_context.verify_mode + and not ssl_context.check_hostname + and not options.tls_allow_invalid_hostnames + ): + try: + ssl.match_hostname(ssl_sock.getpeercert(), hostname=host) # type:ignore[attr-defined, unused-ignore] + except _CertificateError: + ssl_sock.close() + raise + + ssl_sock.settimeout(options.socket_timeout) + return ssl_sock + + async def _configured_protocol_interface( - address: _Address, - options: PoolOptions, - protocol_kls: type[PyMongoBaseProtocol] = PyMongoProtocol, + address: _Address, options: PoolOptions ) -> AsyncNetworkingInterface: """Given (host, port) and PoolOptions, return a configured AsyncNetworkingInterface. @@ -264,7 +316,7 @@ async def _configured_protocol_interface( if ssl_context is None: return AsyncNetworkingInterface( await asyncio.get_running_loop().create_connection( - lambda: protocol_kls(timeout=timeout), sock=sock + lambda: PyMongoProtocol(timeout=timeout), sock=sock ) ) @@ -273,7 +325,7 @@ async def _configured_protocol_interface( # We have to pass hostname / ip address to wrap_socket # to use SSLContext.check_hostname. transport, protocol = await asyncio.get_running_loop().create_connection( # type: ignore[call-overload] - lambda: protocol_kls(timeout=timeout), + lambda: PyMongoProtocol(timeout=timeout), sock=sock, server_hostname=host, ssl=ssl_context, @@ -373,9 +425,56 @@ def _create_connection(address: _Address, options: PoolOptions) -> socket.socket raise OSError("getaddrinfo failed") -def _configured_socket_interface( - address: _Address, options: PoolOptions, *args: Any -) -> NetworkingInterface: +def _configured_socket(address: _Address, options: PoolOptions) -> Union[socket.socket, _sslConn]: + """Given (host, port) and PoolOptions, return a raw configured socket. + + Can raise socket.error, ConnectionFailure, or _CertificateError. + + Sets socket's SSL and timeout options. + """ + sock = _create_connection(address, options) + ssl_context = options._ssl_context + + if ssl_context is None: + sock.settimeout(options.socket_timeout) + return sock + + host = address[0] + try: + # We have to pass hostname / ip address to wrap_socket + # to use SSLContext.check_hostname. + if _has_sni(True): + ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host) # type: ignore[assignment, misc, unused-ignore] + else: + ssl_sock = ssl_context.wrap_socket(sock) # type: ignore[assignment, misc, unused-ignore] + except _CertificateError: + sock.close() + # Raise _CertificateError directly like we do after match_hostname + # below. + raise + except (OSError, *SSLErrors) as exc: + sock.close() + # We raise AutoReconnect for transient and permanent SSL handshake + # failures alike. Permanent handshake failures, like protocol + # mismatch, will be turned into ServerSelectionTimeoutErrors later. + details = _get_timeout_details(options) + _raise_connection_failure(address, exc, "SSL handshake failed: ", timeout_details=details) + if ( + ssl_context.verify_mode + and not ssl_context.check_hostname + and not options.tls_allow_invalid_hostnames + ): + try: + ssl.match_hostname(ssl_sock.getpeercert(), hostname=host) # type:ignore[attr-defined, unused-ignore] + except _CertificateError: + ssl_sock.close() + raise + + ssl_sock.settimeout(options.socket_timeout) + return ssl_sock + + +def _configured_socket_interface(address: _Address, options: PoolOptions) -> NetworkingInterface: """Given (host, port) and PoolOptions, return a NetworkingInterface wrapping a configured socket. Can raise socket.error, ConnectionFailure, or _CertificateError. diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index a08302c211..f9d51a9eab 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -71,11 +71,11 @@ ServerSelectionTimeoutError, ) from pymongo.helpers_shared import _get_timeout_details -from pymongo.network_layer import PyMongoKMSProtocol, receive_kms, sendall +from pymongo.network_layer import sendall from pymongo.operations import UpdateOne from pymongo.pool_options import PoolOptions from pymongo.pool_shared import ( - _configured_socket_interface, + _configured_socket, _raise_connection_failure, ) from pymongo.read_concern import ReadConcern @@ -85,7 +85,6 @@ from pymongo.synchronous.cursor import Cursor from pymongo.synchronous.database import Database from pymongo.synchronous.mongo_client import MongoClient -from pymongo.synchronous.pool import BaseConnection from pymongo.typings import _DocumentType, _DocumentTypeArg from pymongo.uri_parser_shared import _parse_kms_tls_options, parse_host from pymongo.write_concern import WriteConcern @@ -93,8 +92,10 @@ if TYPE_CHECKING: from pymongocrypt.mongocrypt import MongoCryptKmsContext + from pymongo.pyopenssl_context import _sslConn from pymongo.typings import _Address + _IS_SYNC = True _HTTPS_PORT = 443 @@ -109,10 +110,9 @@ _KEY_VAULT_OPTS = CodecOptions(document_class=RawBSONDocument) -def _connect_kms(address: _Address, opts: PoolOptions) -> BaseConnection: +def _connect_kms(address: _Address, opts: PoolOptions) -> Union[socket.socket, _sslConn]: try: - interface = _configured_socket_interface(address, opts, PyMongoKMSProtocol) - return BaseConnection(interface, opts) + return _configured_socket(address, opts) except Exception as exc: _raise_connection_failure(address, exc, timeout_details=_get_timeout_details(opts)) @@ -197,11 +197,19 @@ def kms_request(self, kms_context: MongoCryptKmsContext) -> None: try: conn = _connect_kms(address, opts) try: - sendall(conn.conn.get_conn, message) + sendall(conn, message) while kms_context.bytes_needed > 0: # CSOT: update timeout. - conn.set_conn_timeout(max(_csot.clamp_remaining(_KMS_CONNECT_TIMEOUT), 0)) - data = receive_kms(conn, kms_context.bytes_needed) + conn.settimeout(max(_csot.clamp_remaining(_KMS_CONNECT_TIMEOUT), 0)) + data: memoryview | bytes + if _IS_SYNC: + data = conn.recv(kms_context.bytes_needed) + else: + from pymongo.network_layer import ( # type: ignore[attr-defined] + receive_data_socket, + ) + + data = receive_data_socket(conn, kms_context.bytes_needed) if not data: raise OSError("KMS connection closed") kms_context.feed(data) @@ -220,7 +228,7 @@ def kms_request(self, kms_context: MongoCryptKmsContext) -> None: address, exc, msg_prefix=msg_prefix, timeout_details=_get_timeout_details(opts) ) finally: - conn.close_conn(None) + conn.close() except MongoCryptError: raise # Propagate MongoCryptError errors directly. except Exception as exc: diff --git a/pymongo/synchronous/pool.py b/pymongo/synchronous/pool.py index f35ca4d0fd..f7f6a26c68 100644 --- a/pymongo/synchronous/pool.py +++ b/pymongo/synchronous/pool.py @@ -123,89 +123,7 @@ def _set_non_inheritable_non_atomic(fd: int) -> None: # noqa: ARG001 _IS_SYNC = True -class BaseConnection: - """A base connection object for server and kms connections.""" - - def __init__(self, conn: NetworkingInterface, opts: PoolOptions): - self.conn = conn - self.socket_checker: SocketChecker = SocketChecker() - self.cancel_context: _CancellationContext = _CancellationContext() - self.is_sdam = False - self.closed = False - self.last_timeout: float | None = None - self.more_to_come = False - self.opts = opts - self.max_wire_version = -1 - - def set_conn_timeout(self, timeout: Optional[float]) -> None: - """Cache last timeout to avoid duplicate calls to conn.settimeout.""" - if timeout == self.last_timeout: - return - self.last_timeout = timeout - self.conn.get_conn.settimeout(timeout) - - def apply_timeout( - self, client: MongoClient[Any], cmd: Optional[MutableMapping[str, Any]] - ) -> Optional[float]: - # CSOT: use remaining timeout when set. - timeout = _csot.remaining() - if timeout is None: - # Reset the socket timeout unless we're performing a streaming monitor check. - if not self.more_to_come: - self.set_conn_timeout(self.opts.socket_timeout) - return None - # RTT validation. - rtt = _csot.get_rtt() - if rtt is None: - rtt = self.connect_rtt - max_time_ms = timeout - rtt - if max_time_ms < 0: - timeout_details = _get_timeout_details(self.opts) - formatted = format_timeout_details(timeout_details) - # CSOT: raise an error without running the command since we know it will time out. - errmsg = f"operation would exceed time limit, remaining timeout:{timeout:.5f} <= network round trip time:{rtt:.5f} {formatted}" - if self.max_wire_version != -1: - raise ExecutionTimeout( - errmsg, - 50, - {"ok": 0, "errmsg": errmsg, "code": 50}, - self.max_wire_version, - ) - else: - raise TimeoutError(errmsg) - if cmd is not None: - cmd["maxTimeMS"] = int(max_time_ms * 1000) - self.set_conn_timeout(timeout) - return timeout - - def close_conn(self, reason: Optional[str]) -> None: - """Close this connection with a reason.""" - if self.closed: - return - self._close_conn() - - def _close_conn(self) -> None: - """Close this connection.""" - if self.closed: - return - self.closed = True - self.cancel_context.cancel() - # Note: We catch exceptions to avoid spurious errors on interpreter - # shutdown. - try: - self.conn.close() - except Exception: # noqa: S110 - pass - - def conn_closed(self) -> bool: - """Return True if we know socket has been closed, False otherwise.""" - if _IS_SYNC: - return self.socket_checker.socket_closed(self.conn.get_conn) - else: - return self.conn.is_closing() - - -class Connection(BaseConnection): +class Connection: """Store a connection with some metadata. :param conn: a raw connection object @@ -223,27 +141,29 @@ def __init__( id: int, is_sdam: bool, ): - super().__init__(conn, pool.opts) self.pool_ref = weakref.ref(pool) - self.address: tuple[str, int] = address - self.id: int = id + self.conn = conn + self.address = address + self.id = id self.is_sdam = is_sdam + self.closed = False self.last_checkin_time = time.monotonic() self.performed_handshake = False self.is_writable: bool = False self.max_wire_version = MAX_WIRE_VERSION - self.max_bson_size: int = MAX_BSON_SIZE - self.max_message_size: int = MAX_MESSAGE_SIZE - self.max_write_batch_size: int = MAX_WRITE_BATCH_SIZE + self.max_bson_size = MAX_BSON_SIZE + self.max_message_size = MAX_MESSAGE_SIZE + self.max_write_batch_size = MAX_WRITE_BATCH_SIZE self.supports_sessions = False self.hello_ok: bool = False - self.is_mongos: bool = False + self.is_mongos = False self.op_msg_enabled = False self.listeners = pool.opts._event_listeners self.enabled_for_cmap = pool.enabled_for_cmap self.enabled_for_logging = pool.enabled_for_logging self.compression_settings = pool.opts._compression_settings self.compression_context: Union[SnappyContext, ZlibContext, ZstdContext, None] = None + self.socket_checker: SocketChecker = SocketChecker() self.oidc_token_gen_id: Optional[int] = None # Support for mechanism negotiation on the initial handshake. self.negotiated_mechs: Optional[list[str]] = None @@ -254,6 +174,9 @@ def __init__( self.pool_gen = pool.gen self.generation = self.pool_gen.get_overall() self.ready = False + self.cancel_context: _CancellationContext = _CancellationContext() + self.opts = pool.opts + self.more_to_come: bool = False # For load balancer support. self.service_id: Optional[ObjectId] = None self.server_connection_id: Optional[int] = None @@ -269,6 +192,44 @@ def __init__( # For gossiping $clusterTime from the connection handshake to the client. self._cluster_time = None + def set_conn_timeout(self, timeout: Optional[float]) -> None: + """Cache last timeout to avoid duplicate calls to conn.settimeout.""" + if timeout == self.last_timeout: + return + self.last_timeout = timeout + self.conn.get_conn.settimeout(timeout) + + def apply_timeout( + self, client: MongoClient[Any], cmd: Optional[MutableMapping[str, Any]] + ) -> Optional[float]: + # CSOT: use remaining timeout when set. + timeout = _csot.remaining() + if timeout is None: + # Reset the socket timeout unless we're performing a streaming monitor check. + if not self.more_to_come: + self.set_conn_timeout(self.opts.socket_timeout) + return None + # RTT validation. + rtt = _csot.get_rtt() + if rtt is None: + rtt = self.connect_rtt + max_time_ms = timeout - rtt + if max_time_ms < 0: + timeout_details = _get_timeout_details(self.opts) + formatted = format_timeout_details(timeout_details) + # CSOT: raise an error without running the command since we know it will time out. + errmsg = f"operation would exceed time limit, remaining timeout:{timeout:.5f} <= network round trip time:{rtt:.5f} {formatted}" + raise ExecutionTimeout( + errmsg, + 50, + {"ok": 0, "errmsg": errmsg, "code": 50}, + self.max_wire_version, + ) + if cmd is not None: + cmd["maxTimeMS"] = int(max_time_ms * 1000) + self.set_conn_timeout(timeout) + return timeout + def pin_txn(self) -> None: self.pinned_txn = True assert not self.pinned_cursor @@ -610,6 +571,26 @@ def close_conn(self, reason: Optional[str]) -> None: error=reason, ) + def _close_conn(self) -> None: + """Close this connection.""" + if self.closed: + return + self.closed = True + self.cancel_context.cancel() + # Note: We catch exceptions to avoid spurious errors on interpreter + # shutdown. + try: + self.conn.close() + except Exception: # noqa: S110 + pass + + def conn_closed(self) -> bool: + """Return True if we know socket has been closed, False otherwise.""" + if _IS_SYNC: + return self.socket_checker.socket_closed(self.conn.get_conn) + else: + return self.conn.is_closing() + def send_cluster_time( self, command: MutableMapping[str, Any], diff --git a/tools/synchro.py b/tools/synchro.py index 9a760c0ad7..e502f96281 100644 --- a/tools/synchro.py +++ b/tools/synchro.py @@ -120,9 +120,9 @@ "_async_create_lock": "_create_lock", "_async_create_condition": "_create_condition", "_async_cond_wait": "_cond_wait", - "async_receive_kms": "receive_kms", "AsyncNetworkingInterface": "NetworkingInterface", "_configured_protocol_interface": "_configured_socket_interface", + "_async_configured_socket": "_configured_socket", "SpecRunnerTask": "SpecRunnerThread", "AsyncMockConnection": "MockConnection", "AsyncMockPool": "MockPool", From f33e832beb436d2456138fad2a548cf730c3b5e1 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 30 Sep 2025 11:11:21 -0500 Subject: [PATCH 07/20] PYTHON-5584 Add wheels for Python 3.14 and 3.14t (#2568) --- .github/workflows/dist.yml | 15 ++++----------- doc/changelog.rst | 15 +++++++++++++++ pyproject.toml | 2 -- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index e5b36ad7dd..f9b61d73e5 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -70,24 +70,16 @@ jobs: platforms: all - name: Install cibuildwheel - # Note: the default manylinux is manylinux2014 + # Note: the default manylinux is manylinux_2_28 run: | python -m pip install -U pip - python -m pip install "cibuildwheel>=2.20,<3" + python -m pip install "cibuildwheel>=3.2.0,<4" - name: Build wheels env: CIBW_BUILD: ${{ matrix.buildplat[2] }} run: python -m cibuildwheel --output-dir wheelhouse - - name: Build manylinux1 wheels - if: ${{ matrix.buildplat[1] == 'manylinux_x86_64' || matrix.buildplat[1] == 'manylinux_i686' }} - env: - CIBW_MANYLINUX_X86_64_IMAGE: manylinux1 - CIBW_MANYLINUX_I686_IMAGE: manylinux1 - CIBW_BUILD: "cp39-${{ matrix.buildplat[1] }} cp39-${{ matrix.buildplat[1] }}" - run: python -m cibuildwheel --output-dir wheelhouse - - name: Assert all versions in wheelhouse if: ${{ ! startsWith(matrix.buildplat[1], 'macos') }} run: | @@ -96,8 +88,9 @@ jobs: ls wheelhouse/*cp311*.whl ls wheelhouse/*cp312*.whl ls wheelhouse/*cp313*.whl + ls wheelhouse/*cp314*.whl # Free-threading builds: - ls wheelhouse/*cp313t*.whl + ls wheelhouse/*cp314t*.whl - uses: actions/upload-artifact@v4 with: diff --git a/doc/changelog.rst b/doc/changelog.rst index 082c22fafc..2acbcf976d 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,21 @@ Changelog ========= +Changes in Version 4.15.2 (2025/XX/YY) +-------------------------------------- + +Version 4.15.2 is a bug fix release. + +- Add wheels for Python 3.14 and 3.14t that were missing from 4.15.0 release. Drop the 3.13t wheel. + +Issues Resolved +............... + +See the `PyMongo 4.15.2 release notes in JIRA`_ for the list of resolved issues +in this release. + +.. _PyMongo 4.15.2 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=47186 + Changes in Version 4.15.1 (2025/09/16) -------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 890244b688..e0c028b854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -270,8 +270,6 @@ partial_branches = ["if (.*and +)*not _use_c( and.*)*:"] directory = "htmlcov" [tool.cibuildwheel] -# Enable free-threaded support -free-threaded-support = true skip = "pp* *-musllinux*" build-frontend = "build" test-command = "python {project}/tools/fail_if_no_c.py" From eda41f3e872b0c777c1d8b83322ed4d05702cb82 Mon Sep 17 00:00:00 2001 From: Casey Clements Date: Wed, 1 Oct 2025 17:04:10 -0400 Subject: [PATCH 08/20] final preparation for v4.15.2 release (#2569) --- doc/changelog.rst | 2 +- pymongo/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 2acbcf976d..9ca1d47e98 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,7 +1,7 @@ Changelog ========= -Changes in Version 4.15.2 (2025/XX/YY) +Changes in Version 4.15.2 (2025/10/01) -------------------------------------- Version 4.15.2 is a bug fix release. diff --git a/pymongo/_version.py b/pymongo/_version.py index 7abecf4416..1a00640d78 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.15.1" +__version__ = "4.15.2" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: From a71c96d2ed1a0bfe03d96abed0b2f93dc985febf Mon Sep 17 00:00:00 2001 From: "mongodb-dbx-release-bot[bot]" <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:29:53 +0000 Subject: [PATCH 09/20] BUMP 4.15.3.dev0 Signed-off-by: mongodb-dbx-release-bot[bot] <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> --- pymongo/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymongo/_version.py b/pymongo/_version.py index 1a00640d78..b5c177930c 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.15.2" +__version__ = "4.15.3.dev0" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: From 039c35ba63a2479536975c42bd9eed5d9308c4ca Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Tue, 7 Oct 2025 08:02:42 -0400 Subject: [PATCH 10/20] [v4.15] PYTHON-5571 - Fix memory leak when raising InvalidDocument with C extensions (#2579) --- bson/_cbsonmodule.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bson/_cbsonmodule.c b/bson/_cbsonmodule.c index be91e41734..d7c028dbbc 100644 --- a/bson/_cbsonmodule.c +++ b/bson/_cbsonmodule.c @@ -1657,10 +1657,10 @@ void handle_invalid_doc_error(PyObject* dict) { } if (evalue && PyErr_GivenExceptionMatches(etype, InvalidDocument)) { - PyObject *msg = PyObject_Str(evalue); + msg = PyObject_Str(evalue); if (msg) { // Prepend doc to the existing message - PyObject *dict_str = PyObject_Str(dict); + dict_str = PyObject_Str(dict); if (dict_str == NULL) { goto cleanup; } @@ -1672,15 +1672,17 @@ void handle_invalid_doc_error(PyObject* dict) { if (msg_utf8 == NULL) { goto cleanup; } - PyObject *new_msg = PyUnicode_FromFormat("Invalid document %s | %s", dict_str_utf8, msg_utf8); + new_msg = PyUnicode_FromFormat("Invalid document %s | %s", dict_str_utf8, msg_utf8); Py_DECREF(evalue); Py_DECREF(etype); etype = InvalidDocument; InvalidDocument = NULL; if (new_msg) { evalue = new_msg; + new_msg = NULL; } else { evalue = msg; + msg = NULL; } } PyErr_NormalizeException(&etype, &evalue, &etrace); From fef10d26aa52d2ccab82c8b74e730b3ef3187692 Mon Sep 17 00:00:00 2001 From: "mongodb-dbx-release-bot[bot]" <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:08:33 -0500 Subject: [PATCH 11/20] PYTHON-5596 Fix return type for distinct methods (#2576) [v4.15] (#2583) Co-authored-by: Steven Silvester --- pymongo/asynchronous/collection.py | 2 +- pymongo/asynchronous/cursor.py | 2 +- pymongo/synchronous/collection.py | 2 +- pymongo/synchronous/cursor.py | 2 +- test/test_typing.py | 28 +++++++++++++++++++++++++++- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/pymongo/asynchronous/collection.py b/pymongo/asynchronous/collection.py index 741c11e551..5002bb00ca 100644 --- a/pymongo/asynchronous/collection.py +++ b/pymongo/asynchronous/collection.py @@ -3150,7 +3150,7 @@ async def distinct( comment: Optional[Any] = None, hint: Optional[_IndexKeyHint] = None, **kwargs: Any, - ) -> list[str]: + ) -> list[Any]: """Get a list of distinct values for `key` among all documents in this collection. diff --git a/pymongo/asynchronous/cursor.py b/pymongo/asynchronous/cursor.py index d9fdd576f4..1948bca38a 100644 --- a/pymongo/asynchronous/cursor.py +++ b/pymongo/asynchronous/cursor.py @@ -1064,7 +1064,7 @@ async def close(self) -> None: """Explicitly close / kill this cursor.""" await self._die_lock() - async def distinct(self, key: str) -> list[str]: + async def distinct(self, key: str) -> list[Any]: """Get a list of distinct values for `key` among all documents in the result set of this query. diff --git a/pymongo/synchronous/collection.py b/pymongo/synchronous/collection.py index 9f32deb765..7a1526625d 100644 --- a/pymongo/synchronous/collection.py +++ b/pymongo/synchronous/collection.py @@ -3143,7 +3143,7 @@ def distinct( comment: Optional[Any] = None, hint: Optional[_IndexKeyHint] = None, **kwargs: Any, - ) -> list[str]: + ) -> list[Any]: """Get a list of distinct values for `key` among all documents in this collection. diff --git a/pymongo/synchronous/cursor.py b/pymongo/synchronous/cursor.py index 3dd550f4d5..0370a8023d 100644 --- a/pymongo/synchronous/cursor.py +++ b/pymongo/synchronous/cursor.py @@ -1062,7 +1062,7 @@ def close(self) -> None: """Explicitly close / kill this cursor.""" self._die_lock() - def distinct(self, key: str) -> list[str]: + def distinct(self, key: str) -> list[Any]: """Get a list of distinct values for `key` among all documents in the result set of this query. diff --git a/test/test_typing.py b/test/test_typing.py index 8709186e12..13f2a489fa 100644 --- a/test/test_typing.py +++ b/test/test_typing.py @@ -69,7 +69,7 @@ class ImplicitMovie(TypedDict): from test import IntegrationTest, PyMongoTestCase, client_context -from bson import CodecOptions, decode, decode_all, decode_file_iter, decode_iter, encode +from bson import CodecOptions, ObjectId, decode, decode_all, decode_file_iter, decode_iter, encode from bson.raw_bson import RawBSONDocument from bson.son import SON from pymongo import ASCENDING, MongoClient @@ -141,6 +141,32 @@ def to_list(iterable: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: docs = to_list(cursor) self.assertTrue(docs) + def test_distinct(self) -> None: + self.coll.delete_many({}) + self.coll.insert_many( + [ + {"_id": None}, + {"_id": 0}, + {"_id": ""}, + {"_id": ObjectId()}, + {"_id": True}, + ] + ) + + def collection_distinct( + collection: Collection, + ) -> list[None | int | str | ObjectId | bool]: + return collection.distinct("_id") + + def cursor_distinct( + collection: Collection, + ) -> list[None | int | str | ObjectId | bool]: + cursor = collection.find() + return cursor.distinct("_id") + + collection_distinct(self.coll) + cursor_distinct(self.coll) + @only_type_check def test_bulk_write(self) -> None: self.coll.insert_one({}) From 611681140700c774d606d0cc1bce288b2b6683f8 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 7 Oct 2025 15:51:44 -0500 Subject: [PATCH 12/20] PYTHON-5609 Prepare for 4.15.3 Release (#2584) --- doc/changelog.rst | 18 ++++++++++++++++++ pymongo/_version.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 9ca1d47e98..bde6125c44 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,24 @@ Changelog ========= +Changes in Version 4.15.3 (2025/10/07) +-------------------------------------- + +Version 4.15.3 is a bug fix release. + +- Fixed a memory leak when raising :class:`bson.errors.InvalidDocument` with C extensions. +- Fixed the return type of the :meth:`~pymongo.asynchronous.collection.AsyncCollection.distinct`, + :meth:`~pymongo.synchronous.collection.Collection.distinct`, :meth:`pymongo.asynchronous.cursor.AsyncCursor.distinct`, + and :meth:`pymongo.asynchronous.cursor.AsyncCursor.distinct` methods. + +Issues Resolved +............... + +See the `PyMongo 4.15.3 release notes in JIRA`_ for the list of resolved issues +in this release. + +.. _PyMongo 4.15.3 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=47293 + Changes in Version 4.15.2 (2025/10/01) -------------------------------------- diff --git a/pymongo/_version.py b/pymongo/_version.py index b5c177930c..e517f56770 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.15.3.dev0" +__version__ = "4.15.3" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: From a5a50a89df8c293c8268c3369d2fc4a129bbbc14 Mon Sep 17 00:00:00 2001 From: "mongodb-dbx-release-bot[bot]" <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 22:01:45 +0000 Subject: [PATCH 13/20] BUMP 4.15.4.dev0 Signed-off-by: mongodb-dbx-release-bot[bot] <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> --- pymongo/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymongo/_version.py b/pymongo/_version.py index e517f56770..2b3e663d14 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.15.3" +__version__ = "4.15.4.dev0" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: From f3ea3738bf4ebd67e88c6a78ad3f42a7b76e3a59 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Mon, 20 Oct 2025 12:32:15 -0400 Subject: [PATCH 14/20] PYTHON-5624 - Add Python 3.14 trove classifier (#2593) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e0c028b854..4cb03b285e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Database", "Typing :: Typed", ] From 477217f6667a6cf28bc33f059b4092cbdb5c463f Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 11 Nov 2025 13:42:52 -0600 Subject: [PATCH 15/20] [TASK]-[PYTHON-5623]: [v4.15] Change with_transaction callback return type to Awaitable (#2612) Co-authored-by: Noah Stapp Co-authored-by: Logan Pulley --- .github/workflows/dist.yml | 2 +- doc/changelog.rst | 16 ++++++++++++++++ doc/index.rst | 4 ++-- pymongo/asynchronous/client_session.py | 4 ++-- test/asynchronous/test_transactions.py | 12 ++++++++++++ test/test_transactions.py | 12 ++++++++++++ tools/synchro.py | 8 ++++++++ 7 files changed, 53 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index f9b61d73e5..3f0379bf19 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -100,7 +100,7 @@ jobs: make_sdist: name: Make SDist - runs-on: macos-13 + runs-on: macos-14 steps: - uses: actions/checkout@v5 with: diff --git a/doc/changelog.rst b/doc/changelog.rst index bde6125c44..a9917d211e 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,22 @@ Changelog ========= +Changes in Version 4.15.4 (2025/11/11) +-------------------------------------- + +Version 4.15.4 is a bug fix release. + +- Relaxed the callback type of :meth:`~pymongo.asynchronous.client_session.AsyncClientSession.with_transaction` to allow the broader Awaitable type rather than only Coroutine objects. +- Added the missing Python 3.14 trove classifier to the package metadata. + +Issues Resolved +............... + +See the `PyMongo 4.15.4 release notes in JIRA`_ for the list of resolved issues +in this release. + +.. _PyMongo 4.15.4 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=47237 + Changes in Version 4.15.3 (2025/10/07) -------------------------------------- diff --git a/doc/index.rst b/doc/index.rst index 85812d1b14..17b12e51a8 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -22,7 +22,7 @@ work with MongoDB from Python. Getting Help ------------ If you're having trouble or have questions about PyMongo, ask your question on -our `MongoDB Community Forum `_. +one of the platforms listed on `Technical Support `_. You may also want to consider a `commercial support subscription `_. Once you get an answer, it'd be great if you could work it back into this @@ -37,7 +37,7 @@ project. Feature Requests / Feedback --------------------------- -Use our `feedback engine `_ +Use our `feedback engine `_ to send us feature requests and general feedback about PyMongo. Contributing diff --git a/pymongo/asynchronous/client_session.py b/pymongo/asynchronous/client_session.py index be02295cea..ce2d7667f5 100644 --- a/pymongo/asynchronous/client_session.py +++ b/pymongo/asynchronous/client_session.py @@ -143,8 +143,8 @@ TYPE_CHECKING, Any, AsyncContextManager, + Awaitable, Callable, - Coroutine, Mapping, MutableMapping, NoReturn, @@ -600,7 +600,7 @@ def _inherit_option(self, name: str, val: _T) -> _T: async def with_transaction( self, - callback: Callable[[AsyncClientSession], Coroutine[Any, Any, _T]], + callback: Callable[[AsyncClientSession], Awaitable[_T]], read_concern: Optional[ReadConcern] = None, write_concern: Optional[WriteConcern] = None, read_preference: Optional[_ServerMode] = None, diff --git a/test/asynchronous/test_transactions.py b/test/asynchronous/test_transactions.py index 5c2a4f6fae..6849da9896 100644 --- a/test/asynchronous/test_transactions.py +++ b/test/asynchronous/test_transactions.py @@ -15,6 +15,7 @@ """Execute Transactions Spec tests.""" from __future__ import annotations +import asyncio import sys from io import BytesIO from test.asynchronous.utils_spec_runner import AsyncSpecRunner @@ -469,6 +470,17 @@ async def callback2(session): async with self.client.start_session() as s: self.assertEqual(await s.with_transaction(callback2), "Foo") + @async_client_context.require_transactions + @async_client_context.require_async + async def test_callback_awaitable_no_coroutine(self): + def callback(_): + future = asyncio.Future() + future.set_result("Foo") + return future + + async with self.client.start_session() as s: + self.assertEqual(await s.with_transaction(callback), "Foo") + @async_client_context.require_transactions async def test_callback_not_retried_after_timeout(self): listener = OvertCommandListener() diff --git a/test/test_transactions.py b/test/test_transactions.py index f4578deddb..0c5bd42d7d 100644 --- a/test/test_transactions.py +++ b/test/test_transactions.py @@ -15,6 +15,7 @@ """Execute Transactions Spec tests.""" from __future__ import annotations +import asyncio import sys from io import BytesIO from test.utils_spec_runner import SpecRunner @@ -461,6 +462,17 @@ def callback2(session): with self.client.start_session() as s: self.assertEqual(s.with_transaction(callback2), "Foo") + @client_context.require_transactions + @client_context.require_async + def test_callback_awaitable_no_coroutine(self): + def callback(_): + future = asyncio.Future() + future.set_result("Foo") + return future + + with self.client.start_session() as s: + self.assertEqual(s.with_transaction(callback), "Foo") + @client_context.require_transactions def test_callback_not_retried_after_timeout(self): listener = OvertCommandListener() diff --git a/tools/synchro.py b/tools/synchro.py index e502f96281..17dac3f21f 100644 --- a/tools/synchro.py +++ b/tools/synchro.py @@ -322,6 +322,14 @@ def translate_coroutine_types(lines: list[str]) -> list[str]: index = lines.index(type) new = type.replace(old, res.group(3)) lines[index] = new + coroutine_types = [line for line in lines if "Awaitable[" in line] + for type in coroutine_types: + res = re.search(r"Awaitable\[([A-z]+)\]", type) + if res: + old = res[0] + index = lines.index(type) + new = type.replace(old, res.group(1)) + lines[index] = new return lines From 07d51b10a3f90857549dcf2c06a168dc9e30396e Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 11 Nov 2025 14:32:15 -0600 Subject: [PATCH 16/20] PYTHON-5639 Prep for 4.15.4 release (#2614) --- pymongo/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymongo/_version.py b/pymongo/_version.py index 2b3e663d14..9a0d18dec0 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.15.4.dev0" +__version__ = "4.15.4" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: From dfecbdb438995f683221e96ef5098bedfc5b8f59 Mon Sep 17 00:00:00 2001 From: "mongodb-dbx-release-bot[bot]" <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 20:56:50 +0000 Subject: [PATCH 17/20] BUMP 4.15.5.dev0 Signed-off-by: mongodb-dbx-release-bot[bot] <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> --- pymongo/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymongo/_version.py b/pymongo/_version.py index 9a0d18dec0..db45e19cc7 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.15.4" +__version__ = "4.15.5.dev0" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: From 0480525c22a0e3f46e03f5f3e17b6f5d4049f489 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Mon, 24 Nov 2025 14:25:54 -0500 Subject: [PATCH 18/20] [v4.15] PYTHON-5642 - getMore operations should do server selection if the server is unknown (#2624) --- doc/changelog.rst | 17 ++++++++- pymongo/topology_description.py | 2 +- test/asynchronous/test_server_selection.py | 40 ++++++++++++++++++++-- test/test_server_selection.py | 38 ++++++++++++++++++-- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index a9917d211e..d20502184a 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,7 +1,22 @@ Changelog ========= -Changes in Version 4.15.4 (2025/11/11) +Changes in Version 4.15.5 (2025/11/25) +-------------------------------------- + +Version 4.15.5 is a bug fix release. + +- Fixed a bug that could cause ``AutoReconnect("connection pool paused")`` errors when cursors fetched more documents from the database after SDAM heartbeat failures. + +Issues Resolved +............... + +See the `PyMongo 4.15.5 release notes in JIRA`_ for the list of resolved issues +in this release. + +.. _PyMongo 4.15.5 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=47640 + +Changes in Version 4.15.4 (2025/10/21) -------------------------------------- Version 4.15.4 is a bug fix release. diff --git a/pymongo/topology_description.py b/pymongo/topology_description.py index de67a8f94a..a315c1b885 100644 --- a/pymongo/topology_description.py +++ b/pymongo/topology_description.py @@ -322,7 +322,7 @@ def apply_selector( if address: # Ignore selectors when explicit address is requested. description = self.server_descriptions().get(address) - return [description] if description else [] + return [description] if description and description.is_server_type_known else [] # Primary selection fast path. if self.topology_type == TOPOLOGY_TYPE.ReplicaSetWithPrimary and type(selector) is Primary: diff --git a/test/asynchronous/test_server_selection.py b/test/asynchronous/test_server_selection.py index f570662b85..b704fcea83 100644 --- a/test/asynchronous/test_server_selection.py +++ b/test/asynchronous/test_server_selection.py @@ -17,9 +17,10 @@ import os import sys +import time from pathlib import Path -from pymongo import AsyncMongoClient, ReadPreference +from pymongo import AsyncMongoClient, ReadPreference, monitoring from pymongo.asynchronous.settings import TopologySettings from pymongo.asynchronous.topology import Topology from pymongo.errors import ServerSelectionTimeoutError @@ -30,7 +31,7 @@ sys.path[0:0] = [""] -from test.asynchronous import AsyncIntegrationTest, async_client_context, unittest +from test.asynchronous import AsyncIntegrationTest, async_client_context, client_knobs, unittest from test.asynchronous.utils import async_wait_until from test.asynchronous.utils_selection_tests import ( create_selection_tests, @@ -42,6 +43,7 @@ ) from test.utils_shared import ( FunctionCallRecorder, + HeartbeatEventListener, OvertCommandListener, ) @@ -207,6 +209,40 @@ async def test_server_selector_bypassed(self): ) self.assertEqual(selector.call_count, 0) + @async_client_context.require_replica_set + @async_client_context.require_failCommand_appName + async def test_server_selection_getMore_blocks(self): + hb_listener = HeartbeatEventListener() + client = await self.async_rs_client( + event_listeners=[hb_listener], heartbeatFrequencyMS=500, appName="heartbeatFailedClient" + ) + coll = client.db.test + await coll.drop() + docs = [{"x": 1} for _ in range(5)] + await coll.insert_many(docs) + + fail_heartbeat = { + "configureFailPoint": "failCommand", + "mode": {"times": 4}, + "data": { + "failCommands": [HelloCompat.LEGACY_CMD, "hello"], + "closeConnection": True, + "appName": "heartbeatFailedClient", + }, + } + + def hb_failed(event): + return isinstance(event, monitoring.ServerHeartbeatFailedEvent) + + cursor = coll.find({}, batch_size=1) + await cursor.next() # force initial query that will pin the address for the getMore + + async with self.fail_point(fail_heartbeat): + await async_wait_until( + lambda: hb_listener.matching(hb_failed), "published failed event" + ) + self.assertEqual(len(await cursor.to_list()), 4) + if __name__ == "__main__": unittest.main() diff --git a/test/test_server_selection.py b/test/test_server_selection.py index 4384deac2b..d94e9ed0a1 100644 --- a/test/test_server_selection.py +++ b/test/test_server_selection.py @@ -17,9 +17,10 @@ import os import sys +import time from pathlib import Path -from pymongo import MongoClient, ReadPreference +from pymongo import MongoClient, ReadPreference, monitoring from pymongo.errors import ServerSelectionTimeoutError from pymongo.hello import HelloCompat from pymongo.operations import _Op @@ -30,7 +31,7 @@ sys.path[0:0] = [""] -from test import IntegrationTest, client_context, unittest +from test import IntegrationTest, client_context, client_knobs, unittest from test.utils import wait_until from test.utils_selection_tests import ( create_selection_tests, @@ -42,6 +43,7 @@ ) from test.utils_shared import ( FunctionCallRecorder, + HeartbeatEventListener, OvertCommandListener, ) @@ -205,6 +207,38 @@ def test_server_selector_bypassed(self): topology.select_server(writable_server_selector, _Op.TEST, server_selection_timeout=0.1) self.assertEqual(selector.call_count, 0) + @client_context.require_replica_set + @client_context.require_failCommand_appName + def test_server_selection_getMore_blocks(self): + hb_listener = HeartbeatEventListener() + client = self.rs_client( + event_listeners=[hb_listener], heartbeatFrequencyMS=500, appName="heartbeatFailedClient" + ) + coll = client.db.test + coll.drop() + docs = [{"x": 1} for _ in range(5)] + coll.insert_many(docs) + + fail_heartbeat = { + "configureFailPoint": "failCommand", + "mode": {"times": 4}, + "data": { + "failCommands": [HelloCompat.LEGACY_CMD, "hello"], + "closeConnection": True, + "appName": "heartbeatFailedClient", + }, + } + + def hb_failed(event): + return isinstance(event, monitoring.ServerHeartbeatFailedEvent) + + cursor = coll.find({}, batch_size=1) + cursor.next() # force initial query that will pin the address for the getMore + + with self.fail_point(fail_heartbeat): + wait_until(lambda: hb_listener.matching(hb_failed), "published failed event") + self.assertEqual(len(cursor.to_list()), 4) + if __name__ == "__main__": unittest.main() From 7a8420caad3244afe6f44af6e9caf3d6d25c1247 Mon Sep 17 00:00:00 2001 From: Casey Clements Date: Tue, 2 Dec 2025 13:22:37 -0500 Subject: [PATCH 19/20] PYTHON-5642 Prep for 4.15.5 patch release. (#2633) --- doc/changelog.rst | 2 +- pymongo/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index d20502184a..940a6a6642 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,7 +1,7 @@ Changelog ========= -Changes in Version 4.15.5 (2025/11/25) +Changes in Version 4.15.5 (2025/12/02) -------------------------------------- Version 4.15.5 is a bug fix release. diff --git a/pymongo/_version.py b/pymongo/_version.py index db45e19cc7..df584e6277 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.15.5.dev0" +__version__ = "4.15.5" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: From 966d6c7d99aea276e84918f9c5376ce328791696 Mon Sep 17 00:00:00 2001 From: "mongodb-dbx-release-bot[bot]" <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:48:51 +0000 Subject: [PATCH 20/20] BUMP 4.15.6.dev0 Signed-off-by: mongodb-dbx-release-bot[bot] <167856002+mongodb-dbx-release-bot[bot]@users.noreply.github.com> --- pymongo/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymongo/_version.py b/pymongo/_version.py index df584e6277..4e7eeece16 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.15.5" +__version__ = "4.15.6.dev0" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: