Merge branch 'master' of github.com:mongodb/mongo-python-driver

This commit is contained in:
Steven Silvester 2025-04-21 20:07:31 -05:00
commit da0d5ecdcb
No known key found for this signature in database
GPG Key ID: B1BF5EC3A8B32F91
14 changed files with 199 additions and 43 deletions

View File

@ -620,7 +620,7 @@ buildvariants:
- macos-14
batchtime: 10080
expansions:
TEST_NAME: pyopenssl
SUB_TEST_NAME: pyopenssl
PYTHON_BINARY: /Library/Frameworks/Python.Framework/Versions/3.9/bin/python3
- name: pyopenssl-rhel8-python3.10
tasks:
@ -631,7 +631,7 @@ buildvariants:
- rhel87-small
batchtime: 10080
expansions:
TEST_NAME: pyopenssl
SUB_TEST_NAME: pyopenssl
PYTHON_BINARY: /opt/python/3.10/bin/python3
- name: pyopenssl-rhel8-python3.11
tasks:
@ -642,7 +642,7 @@ buildvariants:
- rhel87-small
batchtime: 10080
expansions:
TEST_NAME: pyopenssl
SUB_TEST_NAME: pyopenssl
PYTHON_BINARY: /opt/python/3.11/bin/python3
- name: pyopenssl-rhel8-python3.12
tasks:
@ -653,7 +653,7 @@ buildvariants:
- rhel87-small
batchtime: 10080
expansions:
TEST_NAME: pyopenssl
SUB_TEST_NAME: pyopenssl
PYTHON_BINARY: /opt/python/3.12/bin/python3
- name: pyopenssl-win64-python3.13
tasks:
@ -664,7 +664,7 @@ buildvariants:
- windows-64-vsMulti-small
batchtime: 10080
expansions:
TEST_NAME: pyopenssl
SUB_TEST_NAME: pyopenssl
PYTHON_BINARY: C:/python/Python313/python.exe
- name: pyopenssl-rhel8-pypy3.10
tasks:
@ -675,7 +675,7 @@ buildvariants:
- rhel87-small
batchtime: 10080
expansions:
TEST_NAME: pyopenssl
SUB_TEST_NAME: pyopenssl
PYTHON_BINARY: /opt/python/pypy3.10/bin/python3
# Search index tests

View File

@ -250,7 +250,7 @@ def create_enterprise_auth_variants():
def create_pyopenssl_variants():
base_name = "PyOpenSSL"
batchtime = BATCHTIME_WEEK
expansions = dict(TEST_NAME="pyopenssl")
expansions = dict(SUB_TEST_NAME="pyopenssl")
variants = []
for python in ALL_PYTHONS:

View File

@ -181,7 +181,7 @@ def run() -> None:
return
if os.environ.get("DEBUG_LOG"):
TEST_ARGS.extend(f"-o log_cli_level={logging.DEBUG} -o log_cli=1".split())
TEST_ARGS.extend(f"-o log_cli_level={logging.DEBUG}".split())
# Run local tests.
ret = pytest.main(TEST_ARGS + sys.argv[1:])

View File

@ -20,4 +20,6 @@ if [ -f $SCRIPT_DIR/env.sh ]; then
source $SCRIPT_DIR/env.sh
fi
echo "Setting up tests with args \"$*\"..."
uv run $SCRIPT_DIR/setup_tests.py "$@"
echo "Setting up tests with args \"$*\"... done."

View File

@ -394,8 +394,8 @@ def handle_test_env() -> None:
load_config_from_file(csfle_dir / "secrets-export.sh")
run_command(f"bash {csfle_dir.as_posix()}/start-servers.sh")
if sub_test_name == "pyopenssl":
UV_ARGS.append("--extra ocsp")
if sub_test_name == "pyopenssl":
UV_ARGS.append("--extra ocsp")
if is_set("TEST_CRYPT_SHARED") or opts.crypt_shared:
config = read_env(f"{DRIVERS_TOOLS}/mo-expansion.sh")
@ -468,9 +468,7 @@ def handle_test_env() -> None:
UV_ARGS.append(f"--group {framework}")
else:
# Use --capture=tee-sys so pytest prints test output inline:
# https://docs.pytest.org/en/stable/how-to/capture-stdout-stderr.html
TEST_ARGS = f"-v --capture=tee-sys --durations=5 {TEST_ARGS}"
TEST_ARGS = f"-v --durations=5 {TEST_ARGS}"
TEST_SUITE = TEST_SUITE_MAP.get(test_name)
if TEST_SUITE:
TEST_ARGS = f"-m {TEST_SUITE} {TEST_ARGS}"

View File

@ -43,7 +43,6 @@ TEST_SUITE_MAP = {
"kms": "kms",
"load_balancer": "load_balancer",
"mockupdb": "mockupdb",
"pyopenssl": "",
"ocsp": "ocsp",
"perf": "perf",
"serverless": "",

View File

@ -352,11 +352,11 @@ If you are running one of the `no-responder` tests, omit the `run-server` step.
## Enable Debug Logs
- Use `-o log_cli_level="DEBUG" -o log_cli=1` with `just test` or `pytest`.
- Add `log_cli_level = "DEBUG` and `log_cli = 1` to the `tool.pytest.ini_options` section in `pyproject.toml` for Evergreen patches or to enable debug logs by default on your machine.
- You can also set `DEBUG_LOG=1` and run either `just setup-tests` or `just-test`.
- Use `-o log_cli_level="DEBUG" -o log_cli=1` with `just test` or `pytest` to output all debug logs to the terminal. **Warning**: This will output a huge amount of logs.
- Add `log_cli=1` and `log_cli_level="DEBUG"` to the `tool.pytest.ini_options` section in `pyproject.toml` to enable debug logs in this manner by default on your machine.
- Set `DEBUG_LOG=1` and run `just setup-tests`, `just-test`, or `pytest` to enable debug logs only for failed tests.
- Finally, you can use `just setup-tests --debug-log`.
- For evergreen patch builds, you can use `evergreen patch --param DEBUG_LOG=1` to enable debug logs for the patch.
- For evergreen patch builds, you can use `evergreen patch --param DEBUG_LOG=1` to enable debug logs for failed tests in the patch.
## Adding a new test suite

View File

@ -10,10 +10,13 @@ Version 4.12.1 is a bug fix release.
- Fixed a bug that could raise ``UnboundLocalError`` when creating asynchronous connections over SSL.
- Fixed a bug causing SRV hostname validation to fail when resolver and resolved hostnames are identical with three domain levels.
- Fixed a bug that caused direct use of ``pymongo.uri_parser`` to raise an ``AttributeError``.
- Fixed a bug where clients created with connect=False and a "mongodb+srv://" connection string
could cause public ``pymongo.MongoClient`` and ``pymongo.AsyncMongoClient`` attributes (topology_description,
nodes, address, primary, secondaries, arbiters) to incorrectly return a Database, leading to type
errors such as: "NotImplementedError: Database objects do not implement truth value testing or bool()".
- Removed Eventlet testing against Python versions newer than 3.9 since
Eventlet is actively being sunset by its maintainers and has compatibility issues with PyMongo's dnspython dependency.
Issues Resolved
...............

View File

@ -109,6 +109,7 @@ from pymongo.operations import (
)
from pymongo.read_preferences import ReadPreference, _ServerMode
from pymongo.results import ClientBulkWriteResult
from pymongo.server_description import ServerDescription
from pymongo.server_selectors import writable_server_selector
from pymongo.server_type import SERVER_TYPE
from pymongo.topology_description import TOPOLOGY_TYPE, TopologyDescription
@ -779,7 +780,7 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
keyword_opts["document_class"] = doc_class
self._resolve_srv_info: dict[str, Any] = {"keyword_opts": keyword_opts}
seeds = set()
self._seeds = set()
is_srv = False
username = None
password = None
@ -804,18 +805,18 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
srv_max_hosts=srv_max_hosts,
)
is_srv = entity.startswith(SRV_SCHEME)
seeds.update(res["nodelist"])
self._seeds.update(res["nodelist"])
username = res["username"] or username
password = res["password"] or password
dbase = res["database"] or dbase
opts = res["options"]
fqdn = res["fqdn"]
else:
seeds.update(split_hosts(entity, self._port))
if not seeds:
self._seeds.update(split_hosts(entity, self._port))
if not self._seeds:
raise ConfigurationError("need to specify at least one host")
for hostname in [node[0] for node in seeds]:
for hostname in [node[0] for node in self._seeds]:
if _detect_external_db(hostname):
break
@ -838,7 +839,7 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
srv_service_name = opts.get("srvServiceName", common.SRV_SERVICE_NAME)
srv_max_hosts = srv_max_hosts or opts.get("srvmaxhosts")
opts = self._normalize_and_validate_options(opts, seeds)
opts = self._normalize_and_validate_options(opts, self._seeds)
# Username and password passed as kwargs override user info in URI.
username = opts.get("username", username)
@ -857,7 +858,7 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
"username": username,
"password": password,
"dbase": dbase,
"seeds": seeds,
"seeds": self._seeds,
"fqdn": fqdn,
"srv_service_name": srv_service_name,
"pool_class": pool_class,
@ -873,8 +874,7 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
self._options.read_concern,
)
if not is_srv:
self._init_based_on_options(seeds, srv_max_hosts, srv_service_name)
self._init_based_on_options(self._seeds, srv_max_hosts, srv_service_name)
self._opened = False
self._closed = False
@ -975,6 +975,7 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
srv_service_name=srv_service_name,
srv_max_hosts=srv_max_hosts,
server_monitoring_mode=self._options.server_monitoring_mode,
topology_id=self._topology_settings._topology_id if self._topology_settings else None,
)
if self._options.auto_encryption_opts:
from pymongo.asynchronous.encryption import _Encrypter
@ -1205,6 +1206,16 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
.. versionadded:: 4.0
"""
if self._topology is None:
servers = {(host, port): ServerDescription((host, port)) for host, port in self._seeds}
return TopologyDescription(
TOPOLOGY_TYPE.Unknown,
servers,
None,
None,
None,
self._topology_settings,
)
return self._topology.description
@property
@ -1218,6 +1229,8 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
to any servers, or a network partition causes it to lose connection
to all servers.
"""
if self._topology is None:
return frozenset()
description = self._topology.description
return frozenset(s.address for s in description.known_servers)
@ -1576,6 +1589,8 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
.. versionadded:: 3.0
"""
if self._topology is None:
await self._get_topology()
topology_type = self._topology._description.topology_type
if (
topology_type == TOPOLOGY_TYPE.Sharded
@ -1598,6 +1613,8 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
.. versionadded:: 3.0
AsyncMongoClient gained this property in version 3.0.
"""
if self._topology is None:
await self._get_topology()
return await self._topology.get_primary() # type: ignore[return-value]
@property
@ -1611,6 +1628,8 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
.. versionadded:: 3.0
AsyncMongoClient gained this property in version 3.0.
"""
if self._topology is None:
await self._get_topology()
return await self._topology.get_secondaries()
@property
@ -1621,6 +1640,8 @@ class AsyncMongoClient(common.BaseObject, Generic[_DocumentType]):
connected to a replica set, there are no arbiters, or this client was
created without the `replicaSet` option.
"""
if self._topology is None:
await self._get_topology()
return await self._topology.get_arbiters()
@property

View File

@ -51,6 +51,7 @@ class TopologySettings:
srv_service_name: str = common.SRV_SERVICE_NAME,
srv_max_hosts: int = 0,
server_monitoring_mode: str = common.SERVER_MONITORING_MODE,
topology_id: Optional[ObjectId] = None,
):
"""Represent MongoClient's configuration.
@ -78,8 +79,10 @@ class TopologySettings:
self._srv_service_name = srv_service_name
self._srv_max_hosts = srv_max_hosts or 0
self._server_monitoring_mode = server_monitoring_mode
self._topology_id = ObjectId()
if topology_id is not None:
self._topology_id = topology_id
else:
self._topology_id = ObjectId()
# Store the allocation traceback to catch unclosed clients in the
# test suite.
self._stack = "".join(traceback.format_stack()[:-2])

View File

@ -101,6 +101,7 @@ from pymongo.operations import (
)
from pymongo.read_preferences import ReadPreference, _ServerMode
from pymongo.results import ClientBulkWriteResult
from pymongo.server_description import ServerDescription
from pymongo.server_selectors import writable_server_selector
from pymongo.server_type import SERVER_TYPE
from pymongo.synchronous import client_session, database, uri_parser
@ -777,7 +778,7 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
keyword_opts["document_class"] = doc_class
self._resolve_srv_info: dict[str, Any] = {"keyword_opts": keyword_opts}
seeds = set()
self._seeds = set()
is_srv = False
username = None
password = None
@ -802,18 +803,18 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
srv_max_hosts=srv_max_hosts,
)
is_srv = entity.startswith(SRV_SCHEME)
seeds.update(res["nodelist"])
self._seeds.update(res["nodelist"])
username = res["username"] or username
password = res["password"] or password
dbase = res["database"] or dbase
opts = res["options"]
fqdn = res["fqdn"]
else:
seeds.update(split_hosts(entity, self._port))
if not seeds:
self._seeds.update(split_hosts(entity, self._port))
if not self._seeds:
raise ConfigurationError("need to specify at least one host")
for hostname in [node[0] for node in seeds]:
for hostname in [node[0] for node in self._seeds]:
if _detect_external_db(hostname):
break
@ -836,7 +837,7 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
srv_service_name = opts.get("srvServiceName", common.SRV_SERVICE_NAME)
srv_max_hosts = srv_max_hosts or opts.get("srvmaxhosts")
opts = self._normalize_and_validate_options(opts, seeds)
opts = self._normalize_and_validate_options(opts, self._seeds)
# Username and password passed as kwargs override user info in URI.
username = opts.get("username", username)
@ -855,7 +856,7 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
"username": username,
"password": password,
"dbase": dbase,
"seeds": seeds,
"seeds": self._seeds,
"fqdn": fqdn,
"srv_service_name": srv_service_name,
"pool_class": pool_class,
@ -871,8 +872,7 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
self._options.read_concern,
)
if not is_srv:
self._init_based_on_options(seeds, srv_max_hosts, srv_service_name)
self._init_based_on_options(self._seeds, srv_max_hosts, srv_service_name)
self._opened = False
self._closed = False
@ -973,6 +973,7 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
srv_service_name=srv_service_name,
srv_max_hosts=srv_max_hosts,
server_monitoring_mode=self._options.server_monitoring_mode,
topology_id=self._topology_settings._topology_id if self._topology_settings else None,
)
if self._options.auto_encryption_opts:
from pymongo.synchronous.encryption import _Encrypter
@ -1203,6 +1204,16 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
.. versionadded:: 4.0
"""
if self._topology is None:
servers = {(host, port): ServerDescription((host, port)) for host, port in self._seeds}
return TopologyDescription(
TOPOLOGY_TYPE.Unknown,
servers,
None,
None,
None,
self._topology_settings,
)
return self._topology.description
@property
@ -1216,6 +1227,8 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
to any servers, or a network partition causes it to lose connection
to all servers.
"""
if self._topology is None:
return frozenset()
description = self._topology.description
return frozenset(s.address for s in description.known_servers)
@ -1570,6 +1583,8 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
.. versionadded:: 3.0
"""
if self._topology is None:
self._get_topology()
topology_type = self._topology._description.topology_type
if (
topology_type == TOPOLOGY_TYPE.Sharded
@ -1592,6 +1607,8 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
.. versionadded:: 3.0
MongoClient gained this property in version 3.0.
"""
if self._topology is None:
self._get_topology()
return self._topology.get_primary() # type: ignore[return-value]
@property
@ -1605,6 +1622,8 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
.. versionadded:: 3.0
MongoClient gained this property in version 3.0.
"""
if self._topology is None:
self._get_topology()
return self._topology.get_secondaries()
@property
@ -1615,6 +1634,8 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]):
connected to a replica set, there are no arbiters, or this client was
created without the `replicaSet` option.
"""
if self._topology is None:
self._get_topology()
return self._topology.get_arbiters()
@property

View File

@ -51,6 +51,7 @@ class TopologySettings:
srv_service_name: str = common.SRV_SERVICE_NAME,
srv_max_hosts: int = 0,
server_monitoring_mode: str = common.SERVER_MONITORING_MODE,
topology_id: Optional[ObjectId] = None,
):
"""Represent MongoClient's configuration.
@ -78,8 +79,10 @@ class TopologySettings:
self._srv_service_name = srv_service_name
self._srv_max_hosts = srv_max_hosts or 0
self._server_monitoring_mode = server_monitoring_mode
self._topology_id = ObjectId()
if topology_id is not None:
self._topology_id = topology_id
else:
self._topology_id = ObjectId()
# Store the allocation traceback to catch unclosed clients in the
# test suite.
self._stack = "".join(traceback.format_stack()[:-2])

View File

@ -34,7 +34,7 @@ import threading
import time
import uuid
from typing import Any, Iterable, Type, no_type_check
from unittest import mock
from unittest import mock, skipIf
from unittest.mock import patch
import pytest
@ -629,6 +629,7 @@ class AsyncClientUnitTest(AsyncUnitTest):
logs = [record.getMessage() for record in cm.records if record.name == "pymongo.client"]
self.assertEqual(len(logs), 7)
@skipIf(os.environ.get("DEBUG_LOG"), "Enabling debug logs breaks this test")
@patch("pymongo.asynchronous.srv_resolver._SrvResolver.get_hosts")
async def test_detected_environment_warning(self, mock_get_hosts):
with self._caplog.at_level(logging.WARN):
@ -849,6 +850,58 @@ class TestClient(AsyncIntegrationTest):
with self.assertRaises(ConnectionFailure):
await c.pymongo_test.test.find_one()
@async_client_context.require_no_standalone
@async_client_context.require_no_load_balancer
@async_client_context.require_tls
async def test_init_disconnected_with_srv(self):
c = await self.async_rs_or_single_client(
"mongodb+srv://test1.test.build.10gen.cc", connect=False, tlsInsecure=True
)
# nodes returns an empty set if not connected
self.assertEqual(c.nodes, frozenset())
# topology_description returns the initial seed description if not connected
topology_description = c.topology_description
self.assertEqual(topology_description.topology_type, TOPOLOGY_TYPE.Unknown)
self.assertEqual(
{
("test1.test.build.10gen.cc", None): ServerDescription(
("test1.test.build.10gen.cc", None)
)
},
topology_description.server_descriptions(),
)
# address causes client to block until connected
self.assertIsNotNone(await c.address)
# Initial seed topology and connected topology have the same ID
self.assertEqual(
c._topology._topology_id, topology_description._topology_settings._topology_id
)
await c.close()
c = await self.async_rs_or_single_client(
"mongodb+srv://test1.test.build.10gen.cc", connect=False, tlsInsecure=True
)
# primary causes client to block until connected
await c.primary
self.assertIsNotNone(c._topology)
await c.close()
c = await self.async_rs_or_single_client(
"mongodb+srv://test1.test.build.10gen.cc", connect=False, tlsInsecure=True
)
# secondaries causes client to block until connected
await c.secondaries
self.assertIsNotNone(c._topology)
await c.close()
c = await self.async_rs_or_single_client(
"mongodb+srv://test1.test.build.10gen.cc", connect=False, tlsInsecure=True
)
# arbiters causes client to block until connected
await c.arbiters
self.assertIsNotNone(c._topology)
async def test_equality(self):
seed = "{}:{}".format(*list(self.client._topology_settings.seeds)[0])
c = await self.async_rs_or_single_client(seed, connect=False)

View File

@ -34,7 +34,7 @@ import threading
import time
import uuid
from typing import Any, Iterable, Type, no_type_check
from unittest import mock
from unittest import mock, skipIf
from unittest.mock import patch
import pytest
@ -622,6 +622,7 @@ class ClientUnitTest(UnitTest):
logs = [record.getMessage() for record in cm.records if record.name == "pymongo.client"]
self.assertEqual(len(logs), 7)
@skipIf(os.environ.get("DEBUG_LOG"), "Enabling debug logs breaks this test")
@patch("pymongo.synchronous.srv_resolver._SrvResolver.get_hosts")
def test_detected_environment_warning(self, mock_get_hosts):
with self._caplog.at_level(logging.WARN):
@ -824,6 +825,58 @@ class TestClient(IntegrationTest):
with self.assertRaises(ConnectionFailure):
c.pymongo_test.test.find_one()
@client_context.require_no_standalone
@client_context.require_no_load_balancer
@client_context.require_tls
def test_init_disconnected_with_srv(self):
c = self.rs_or_single_client(
"mongodb+srv://test1.test.build.10gen.cc", connect=False, tlsInsecure=True
)
# nodes returns an empty set if not connected
self.assertEqual(c.nodes, frozenset())
# topology_description returns the initial seed description if not connected
topology_description = c.topology_description
self.assertEqual(topology_description.topology_type, TOPOLOGY_TYPE.Unknown)
self.assertEqual(
{
("test1.test.build.10gen.cc", None): ServerDescription(
("test1.test.build.10gen.cc", None)
)
},
topology_description.server_descriptions(),
)
# address causes client to block until connected
self.assertIsNotNone(c.address)
# Initial seed topology and connected topology have the same ID
self.assertEqual(
c._topology._topology_id, topology_description._topology_settings._topology_id
)
c.close()
c = self.rs_or_single_client(
"mongodb+srv://test1.test.build.10gen.cc", connect=False, tlsInsecure=True
)
# primary causes client to block until connected
c.primary
self.assertIsNotNone(c._topology)
c.close()
c = self.rs_or_single_client(
"mongodb+srv://test1.test.build.10gen.cc", connect=False, tlsInsecure=True
)
# secondaries causes client to block until connected
c.secondaries
self.assertIsNotNone(c._topology)
c.close()
c = self.rs_or_single_client(
"mongodb+srv://test1.test.build.10gen.cc", connect=False, tlsInsecure=True
)
# arbiters causes client to block until connected
c.arbiters
self.assertIsNotNone(c._topology)
def test_equality(self):
seed = "{}:{}".format(*list(self.client._topology_settings.seeds)[0])
c = self.rs_or_single_client(seed, connect=False)