Skip to content

Commit 65f7c54

Browse files
authored
PYTHON-5344 and PYTHON-5403 Allow Instantiated MongoClients to Send Client Metadata On-Demand (#2358)
1 parent e2bfa9a commit 65f7c54

File tree

11 files changed

+599
-12
lines changed

11 files changed

+599
-12
lines changed

.evergreen/resync-specs.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ do
131131
gridfs)
132132
cpjson gridfs/tests gridfs
133133
;;
134+
handshake)
135+
cpjson mongodb-handshake/tests handshake
136+
;;
134137
index|index-management)
135138
cpjson index-management/tests index_management
136139
;;

doc/changelog.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ PyMongo 4.14 brings a number of changes including:
77

88
- Added :attr:`bson.codec_options.TypeRegistry.codecs` and :attr:`bson.codec_options.TypeRegistry.fallback_encoder` properties
99
to allow users to directly access the type codecs and fallback encoder for a given :class:`bson.codec_options.TypeRegistry`.
10+
- Added :meth:`pymongo.asynchronous.mongo_client.AsyncMongoClient.append_metadata` and
11+
:meth:`pymongo.mongo_client.MongoClient.append_metadata` to allow instantiated MongoClients to send client metadata
12+
on-demand
1013

1114
- Introduces a minor breaking change. When encoding :class:`bson.binary.BinaryVector`, a ``ValueError`` will be raised
1215
if the 'padding' metadata field is < 0 or > 7, or non-zero for any type other than PACKED_BIT.

pymongo/asynchronous/mongo_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from pymongo.asynchronous.settings import TopologySettings
7171
from pymongo.asynchronous.topology import Topology, _ErrorContext
7272
from pymongo.client_options import ClientOptions
73+
from pymongo.driver_info import DriverInfo
7374
from pymongo.errors import (
7475
AutoReconnect,
7576
BulkWriteError,
@@ -1040,6 +1041,20 @@ async def target() -> bool:
10401041
self._kill_cursors_executor = executor
10411042
self._opened = False
10421043

1044+
def append_metadata(self, driver_info: DriverInfo) -> None:
1045+
"""Appends the given metadata to existing driver metadata.
1046+
1047+
:param driver_info: a :class:`~pymongo.driver_info.DriverInfo`
1048+
1049+
.. versionadded:: 4.14
1050+
"""
1051+
1052+
if not isinstance(driver_info, DriverInfo):
1053+
raise TypeError(
1054+
f"driver_info must be an instance of DriverInfo, not {type(driver_info)}"
1055+
)
1056+
self._options.pool_options._update_metadata(driver_info)
1057+
10431058
def _should_pin_cursor(self, session: Optional[AsyncClientSession]) -> Optional[bool]:
10441059
return self._options.load_balanced and not (session and session.in_transaction)
10451060

pymongo/pool_options.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -376,25 +376,33 @@ def __init__(
376376
"async",
377377
)
378378
if driver:
379-
if driver.name:
380-
self.__metadata["driver"]["name"] = "{}|{}".format(
381-
self.__metadata["driver"]["name"],
382-
driver.name,
383-
)
384-
if driver.version:
385-
self.__metadata["driver"]["version"] = "{}|{}".format(
386-
_METADATA["driver"]["version"],
387-
driver.version,
388-
)
389-
if driver.platform:
390-
self.__metadata["platform"] = "{}|{}".format(_METADATA["platform"], driver.platform)
379+
self._update_metadata(driver)
391380

392381
env = _metadata_env()
393382
if env:
394383
self.__metadata["env"] = env
395384

396385
_truncate_metadata(self.__metadata)
397386

387+
def _update_metadata(self, driver: DriverInfo) -> None:
388+
"""Updates the client's metadata"""
389+
390+
metadata = copy.deepcopy(self.__metadata)
391+
if driver.name:
392+
metadata["driver"]["name"] = "{}|{}".format(
393+
metadata["driver"]["name"],
394+
driver.name,
395+
)
396+
if driver.version:
397+
metadata["driver"]["version"] = "{}|{}".format(
398+
metadata["driver"]["version"],
399+
driver.version,
400+
)
401+
if driver.platform:
402+
metadata["platform"] = "{}|{}".format(metadata["platform"], driver.platform)
403+
404+
self.__metadata = metadata
405+
398406
@property
399407
def _credentials(self) -> Optional[MongoCredential]:
400408
"""A :class:`~pymongo.auth.MongoCredentials` instance or None."""

pymongo/synchronous/mongo_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
from bson.timestamp import Timestamp
6363
from pymongo import _csot, common, helpers_shared, periodic_executor
6464
from pymongo.client_options import ClientOptions
65+
from pymongo.driver_info import DriverInfo
6566
from pymongo.errors import (
6667
AutoReconnect,
6768
BulkWriteError,
@@ -1040,6 +1041,20 @@ def target() -> bool:
10401041
self._kill_cursors_executor = executor
10411042
self._opened = False
10421043

1044+
def append_metadata(self, driver_info: DriverInfo) -> None:
1045+
"""Appends the given metadata to existing driver metadata.
1046+
1047+
:param driver_info: a :class:`~pymongo.driver_info.DriverInfo`
1048+
1049+
.. versionadded:: 4.14
1050+
"""
1051+
1052+
if not isinstance(driver_info, DriverInfo):
1053+
raise TypeError(
1054+
f"driver_info must be an instance of DriverInfo, not {type(driver_info)}"
1055+
)
1056+
self._options.pool_options._update_metadata(driver_info)
1057+
10431058
def _should_pin_cursor(self, session: Optional[ClientSession]) -> Optional[bool]:
10441059
return self._options.load_balanced and not (session and session.in_transaction)
10451060

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Copyright 2013-present MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from __future__ import annotations
15+
16+
import asyncio
17+
import os
18+
import pathlib
19+
import time
20+
import unittest
21+
from test.asynchronous import AsyncIntegrationTest
22+
from test.asynchronous.unified_format import generate_test_classes
23+
from test.utils_shared import CMAPListener
24+
from typing import Any, Optional
25+
26+
import pytest
27+
28+
from pymongo import AsyncMongoClient
29+
from pymongo.driver_info import DriverInfo
30+
from pymongo.monitoring import ConnectionClosedEvent
31+
32+
try:
33+
from mockupdb import MockupDB, OpMsgReply
34+
35+
_HAVE_MOCKUPDB = True
36+
except ImportError:
37+
_HAVE_MOCKUPDB = False
38+
39+
pytestmark = pytest.mark.mockupdb
40+
41+
_IS_SYNC = False
42+
43+
# Location of JSON test specifications.
44+
if _IS_SYNC:
45+
_TEST_PATH = os.path.join(pathlib.Path(__file__).resolve().parent, "handshake", "unified")
46+
else:
47+
_TEST_PATH = os.path.join(
48+
pathlib.Path(__file__).resolve().parent.parent, "handshake", "unified"
49+
)
50+
51+
# Generate unified tests.
52+
globals().update(generate_test_classes(_TEST_PATH, module=__name__))
53+
54+
55+
def _get_handshake_driver_info(request):
56+
assert "client" in request
57+
return request["client"]
58+
59+
60+
class TestClientMetadataProse(AsyncIntegrationTest):
61+
async def asyncSetUp(self):
62+
await super().asyncSetUp()
63+
self.server = MockupDB()
64+
self.handshake_req = None
65+
66+
def respond(r):
67+
if "ismaster" in r:
68+
# then this is a handshake request
69+
self.handshake_req = r
70+
return r.reply(OpMsgReply(maxWireVersion=13))
71+
72+
self.server.autoresponds(respond)
73+
self.server.run()
74+
self.addAsyncCleanup(self.server.stop)
75+
76+
async def send_ping_and_get_metadata(
77+
self, client: AsyncMongoClient, is_handshake: bool
78+
) -> tuple[str, Optional[str], Optional[str], dict[str, Any]]:
79+
# reset if handshake request
80+
if is_handshake:
81+
self.handshake_req: Optional[dict] = None
82+
83+
await client.admin.command("ping")
84+
metadata = _get_handshake_driver_info(self.handshake_req)
85+
driver_metadata = metadata["driver"]
86+
name, version, platform = (
87+
driver_metadata["name"],
88+
driver_metadata["version"],
89+
metadata["platform"],
90+
)
91+
return name, version, platform, metadata
92+
93+
async def check_metadata_added(
94+
self,
95+
client: AsyncMongoClient,
96+
add_name: str,
97+
add_version: Optional[str],
98+
add_platform: Optional[str],
99+
) -> None:
100+
# send initial metadata
101+
name, version, platform, metadata = await self.send_ping_and_get_metadata(client, True)
102+
# wait for connection to become idle
103+
await asyncio.sleep(0.005)
104+
105+
# add new metadata
106+
client.append_metadata(DriverInfo(add_name, add_version, add_platform))
107+
new_name, new_version, new_platform, new_metadata = await self.send_ping_and_get_metadata(
108+
client, True
109+
)
110+
self.assertEqual(new_name, f"{name}|{add_name}" if add_name is not None else name)
111+
self.assertEqual(
112+
new_version,
113+
f"{version}|{add_version}" if add_version is not None else version,
114+
)
115+
self.assertEqual(
116+
new_platform,
117+
f"{platform}|{add_platform}" if add_platform is not None else platform,
118+
)
119+
120+
metadata.pop("driver")
121+
metadata.pop("platform")
122+
new_metadata.pop("driver")
123+
new_metadata.pop("platform")
124+
self.assertEqual(metadata, new_metadata)
125+
126+
async def test_append_metadata(self):
127+
client = await self.async_rs_or_single_client(
128+
"mongodb://" + self.server.address_string,
129+
maxIdleTimeMS=1,
130+
driver=DriverInfo("library", "1.2", "Library Platform"),
131+
)
132+
await self.check_metadata_added(client, "framework", "2.0", "Framework Platform")
133+
134+
async def test_append_metadata_platform_none(self):
135+
client = await self.async_rs_or_single_client(
136+
"mongodb://" + self.server.address_string,
137+
maxIdleTimeMS=1,
138+
driver=DriverInfo("library", "1.2", "Library Platform"),
139+
)
140+
await self.check_metadata_added(client, "framework", "2.0", None)
141+
142+
async def test_append_metadata_version_none(self):
143+
client = await self.async_rs_or_single_client(
144+
"mongodb://" + self.server.address_string,
145+
maxIdleTimeMS=1,
146+
driver=DriverInfo("library", "1.2", "Library Platform"),
147+
)
148+
await self.check_metadata_added(client, "framework", None, "Framework Platform")
149+
150+
async def test_append_metadata_platform_version_none(self):
151+
client = await self.async_rs_or_single_client(
152+
"mongodb://" + self.server.address_string,
153+
maxIdleTimeMS=1,
154+
driver=DriverInfo("library", "1.2", "Library Platform"),
155+
)
156+
await self.check_metadata_added(client, "framework", None, None)
157+
158+
async def test_multiple_successive_metadata_updates(self):
159+
client = await self.async_rs_or_single_client(
160+
"mongodb://" + self.server.address_string, maxIdleTimeMS=1, connect=False
161+
)
162+
client.append_metadata(DriverInfo("library", "1.2", "Library Platform"))
163+
await self.check_metadata_added(client, "framework", "2.0", "Framework Platform")
164+
165+
async def test_multiple_successive_metadata_updates_platform_none(self):
166+
client = await self.async_rs_or_single_client(
167+
"mongodb://" + self.server.address_string,
168+
maxIdleTimeMS=1,
169+
)
170+
client.append_metadata(DriverInfo("library", "1.2", "Library Platform"))
171+
await self.check_metadata_added(client, "framework", "2.0", None)
172+
173+
async def test_multiple_successive_metadata_updates_version_none(self):
174+
client = await self.async_rs_or_single_client(
175+
"mongodb://" + self.server.address_string,
176+
maxIdleTimeMS=1,
177+
)
178+
client.append_metadata(DriverInfo("library", "1.2", "Library Platform"))
179+
await self.check_metadata_added(client, "framework", None, "Framework Platform")
180+
181+
async def test_multiple_successive_metadata_updates_platform_version_none(self):
182+
client = await self.async_rs_or_single_client(
183+
"mongodb://" + self.server.address_string,
184+
maxIdleTimeMS=1,
185+
)
186+
client.append_metadata(DriverInfo("library", "1.2", "Library Platform"))
187+
await self.check_metadata_added(client, "framework", None, None)
188+
189+
async def test_doesnt_update_established_connections(self):
190+
listener = CMAPListener()
191+
client = await self.async_rs_or_single_client(
192+
"mongodb://" + self.server.address_string,
193+
maxIdleTimeMS=1,
194+
driver=DriverInfo("library", "1.2", "Library Platform"),
195+
event_listeners=[listener],
196+
)
197+
198+
# send initial metadata
199+
name, version, platform, metadata = await self.send_ping_and_get_metadata(client, True)
200+
self.assertIsNotNone(name)
201+
self.assertIsNotNone(version)
202+
self.assertIsNotNone(platform)
203+
204+
# add data
205+
add_name, add_version, add_platform = "framework", "2.0", "Framework Platform"
206+
client.append_metadata(DriverInfo(add_name, add_version, add_platform))
207+
# check new data isn't sent
208+
self.handshake_req: Optional[dict] = None
209+
await client.admin.command("ping")
210+
self.assertIsNone(self.handshake_req)
211+
self.assertEqual(listener.event_count(ConnectionClosedEvent), 0)
212+
213+
214+
if __name__ == "__main__":
215+
unittest.main()

test/asynchronous/unified_format.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
from pymongo.asynchronous.database import AsyncDatabase
7676
from pymongo.asynchronous.encryption import AsyncClientEncryption
7777
from pymongo.asynchronous.helpers import anext
78+
from pymongo.driver_info import DriverInfo
7879
from pymongo.encryption_options import _HAVE_PYMONGOCRYPT
7980
from pymongo.errors import (
8081
AutoReconnect,
@@ -813,6 +814,11 @@ async def _cursor_close(self, target, *args, **kwargs):
813814
self.__raise_if_unsupported("close", target, NonLazyCursor, AsyncCommandCursor)
814815
return await target.close()
815816

817+
async def _clientOperation_appendMetadata(self, target, *args, **kwargs):
818+
info_opts = kwargs["driver_info_options"]
819+
driver_info = DriverInfo(info_opts["name"], info_opts["version"], info_opts["platform"])
820+
target.append_metadata(driver_info)
821+
816822
async def _clientEncryptionOperation_createDataKey(self, target, *args, **kwargs):
817823
if "opts" in kwargs:
818824
kwargs.update(camel_to_snake_args(kwargs.pop("opts")))

0 commit comments

Comments
 (0)