diff --git a/doc/changelog.rst b/doc/changelog.rst index fb07aad6b..47b6a46ac 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -32,6 +32,10 @@ PyMongo 4.7 brings a number of improvements including: - Fixed a bug appearing in Python 3.12 where "RuntimeError: can't create new thread at interpreter shutdown" could be written to stderr when a MongoClient's thread starts as the python interpreter is shutting down. +- Added a warning when connecting to DocumentDB and CosmosDB clusters. + For more information regarding feature compatibility and support please visit + `mongodb.com/supportability/documentdb `_ and + `mongodb.com/supportability/cosmosdb `_. - Added the :attr:`pymongo.monitoring.ConnectionCheckedOutEvent.duration`, :attr:`pymongo.monitoring.ConnectionCheckOutFailedEvent.duration`, and :attr:`pymongo.monitoring.ConnectionReadyEvent.duration` properties. diff --git a/pymongo/logger.py b/pymongo/logger.py index 472a18a52..2caafa778 100644 --- a/pymongo/logger.py +++ b/pymongo/logger.py @@ -16,6 +16,7 @@ from __future__ import annotations import enum import logging import os +import warnings from typing import Any from bson import UuidRepresentation, json_util @@ -71,6 +72,7 @@ _JSON_OPTIONS = JSONOptions(uuid_representation=UuidRepresentation.STANDARD) _COMMAND_LOGGER = logging.getLogger("pymongo.command") _CONNECTION_LOGGER = logging.getLogger("pymongo.connection") _SERVER_SELECTION_LOGGER = logging.getLogger("pymongo.serverSelection") +_CLIENT_LOGGER = logging.getLogger("pymongo.client") _VERBOSE_CONNECTION_ERROR_REASONS = { ConnectionClosedReason.POOL_CLOSED: "Connection pool was closed", ConnectionCheckOutFailedReason.POOL_CLOSED: "Connection pool was closed", @@ -94,6 +96,14 @@ def _info_log(logger: logging.Logger, **fields: Any) -> None: logger.info(LogMessage(**fields)) +def _log_or_warn(logger: logging.Logger, message: str) -> None: + if logger.isEnabledFor(logging.INFO): + logger.info(message) + else: + # stacklevel=4 ensures that the warning is for the user's code. + warnings.warn(message, UserWarning, stacklevel=4) + + class LogMessage: __slots__ = ("_kwargs", "_redacted") diff --git a/pymongo/mongo_client.py b/pymongo/mongo_client.py index 2f405b38d..100a8340a 100644 --- a/pymongo/mongo_client.py +++ b/pymongo/mongo_client.py @@ -86,6 +86,7 @@ from pymongo.errors import ( WriteConcernError, ) from pymongo.lock import _HAS_REGISTER_AT_FORK, _create_lock, _release_locks +from pymongo.logger import _CLIENT_LOGGER, _log_or_warn from pymongo.monitoring import ConnectionClosedReason from pymongo.operations import _Op from pymongo.read_preferences import ReadPreference, _ServerMode @@ -789,6 +790,10 @@ class MongoClient(common.BaseObject, Generic[_DocumentType]): if not seeds: raise ConfigurationError("need to specify at least one host") + for hostname in [node[0] for node in seeds]: + if _detect_external_db(hostname): + break + # Add options with named keyword arguments to the parsed kwarg options. if type_registry is not None: keyword_opts["type_registry"] = type_registry @@ -2487,6 +2492,31 @@ def _after_fork_child() -> None: client._after_fork() +def _detect_external_db(entity: str) -> bool: + """Detects external database hosts and logs an informational message at the INFO level.""" + entity = entity.lower() + cosmos_db_hosts = [".cosmos.azure.com"] + document_db_hosts = [".docdb.amazonaws.com", ".docdb-elastic.amazonaws.com"] + + for host in cosmos_db_hosts: + if entity.endswith(host): + _log_or_warn( + _CLIENT_LOGGER, + "You appear to be connected to a CosmosDB cluster. For more information regarding feature " + "compatibility and support please visit https://www.mongodb.com/supportability/cosmosdb", + ) + return True + for host in document_db_hosts: + if entity.endswith(host): + _log_or_warn( + _CLIENT_LOGGER, + "You appear to be connected to a DocumentDB cluster. For more information regarding feature " + "compatibility and support please visit https://www.mongodb.com/supportability/documentdb", + ) + return True + return False + + if _HAS_REGISTER_AT_FORK: # This will run in the same thread as the fork was called. # If we fork in a critical region on the same thread, it should break. diff --git a/test/test_client.py b/test/test_client.py index 0752911c2..8f7fa6748 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -20,6 +20,7 @@ import contextlib import copy import datetime import gc +import logging import os import re import signal @@ -33,6 +34,8 @@ from typing import Iterable, Type, no_type_check from unittest import mock from unittest.mock import patch +import pytest + from pymongo.operations import _Op sys.path[0:0] = [""] @@ -99,7 +102,7 @@ from pymongo.errors import ( ServerSelectionTimeoutError, WriteConcernError, ) -from pymongo.mongo_client import MongoClient +from pymongo.mongo_client import MongoClient, _detect_external_db from pymongo.monitoring import ServerHeartbeatListener, ServerHeartbeatStartedEvent from pymongo.pool import _METADATA, DOCKER_ENV_PATH, ENV_VAR_K8S, Connection, PoolOptions from pymongo.read_preferences import ReadPreference @@ -126,6 +129,10 @@ class ClientUnitTest(unittest.TestCase): def tearDownClass(cls): cls.client.close() + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + def test_keyword_arg_defaults(self): client = MongoClient( socketTimeoutMS=None, @@ -546,6 +553,78 @@ class ClientUnitTest(unittest.TestCase): with self.assertRaisesRegex(ConfigurationError, expected): MongoClient(**{typo: "standard"}) # type: ignore[arg-type] + @patch("pymongo.srv_resolver._SrvResolver.get_hosts") + def test_detected_environment_logging(self, mock_get_hosts): + normal_hosts = [ + "normal.host.com", + "host.cosmos.azure.com", + "host.docdb.amazonaws.com", + "host.docdb-elastic.amazonaws.com", + ] + srv_hosts = ["mongodb+srv://:@" + s for s in normal_hosts] + multi_host = ( + "host.cosmos.azure.com,host.docdb.amazonaws.com,host.docdb-elastic.amazonaws.com" + ) + with self.assertLogs("pymongo", level="INFO") as cm: + for host in normal_hosts: + MongoClient(host) + for host in srv_hosts: + mock_get_hosts.return_value = [(host, 1)] + MongoClient(host) + MongoClient(multi_host) + logs = [record.message for record in cm.records if record.name == "pymongo.client"] + self.assertEqual(len(logs), 7) + + @patch("pymongo.srv_resolver._SrvResolver.get_hosts") + def test_detected_environment_warning(self, mock_get_hosts): + with self._caplog.at_level(logging.WARN): + normal_hosts = [ + "host.cosmos.azure.com", + "host.docdb.amazonaws.com", + "host.docdb-elastic.amazonaws.com", + ] + srv_hosts = ["mongodb+srv://:@" + s for s in normal_hosts] + multi_host = ( + "host.cosmos.azure.com,host.docdb.amazonaws.com,host.docdb-elastic.amazonaws.com" + ) + for host in normal_hosts: + with self.assertWarns(UserWarning): + MongoClient(host) + for host in srv_hosts: + mock_get_hosts.return_value = [(host, 1)] + with self.assertWarns(UserWarning): + MongoClient(host) + with self.assertWarns(UserWarning): + MongoClient(multi_host) + + def test_detect_external_db(self): + hosts = [ + "normalhost.com", + "host.cosmos.AZURE.com", + "host.docdb.amazonaws.com", + "host.docdb-elastic.amazonaws.com", + ] + with self.assertLogs("pymongo", level="INFO") as cm: + for host in hosts: + _detect_external_db(host) + logs = [record.message for record in cm.records if record.name == "pymongo.client"] + self.assertEqual(len(logs), 3) + self.assertEqual( + logs[0], + "You appear to be connected to a CosmosDB cluster. For more information regarding feature " + "compatibility and support please visit https://www.mongodb.com/supportability/cosmosdb", + ) + self.assertEqual( + logs[1], + "You appear to be connected to a DocumentDB cluster. For more information regarding feature " + "compatibility and support please visit https://www.mongodb.com/supportability/documentdb", + ) + self.assertEqual( + logs[2], + "You appear to be connected to a DocumentDB cluster. For more information regarding feature " + "compatibility and support please visit https://www.mongodb.com/supportability/documentdb", + ) + class TestClient(IntegrationTest): def test_multiple_uris(self): diff --git a/test/test_uri_parser.py b/test/test_uri_parser.py index a4ad908e1..01dff2137 100644 --- a/test/test_uri_parser.py +++ b/test/test_uri_parser.py @@ -28,7 +28,12 @@ from test import unittest from bson.binary import JAVA_LEGACY from pymongo import ReadPreference from pymongo.errors import ConfigurationError, InvalidURI -from pymongo.uri_parser import parse_uri, parse_userinfo, split_hosts, split_options +from pymongo.uri_parser import ( + parse_uri, + parse_userinfo, + split_hosts, + split_options, +) class TestURI(unittest.TestCase):