Skip to content

Commit 23eb52b

Browse files
authored
Merge branch 'main' into PYTHON-5931
2 parents 5a0ef17 + c1748a9 commit 23eb52b

7 files changed

Lines changed: 99 additions & 37 deletions

File tree

.github/workflows/release-python.yml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,6 @@ jobs:
7979
with:
8080
name: all-dist-${{ github.run_id }}
8181
path: dist/
82-
- name: Publish package distributions to TestPyPI
83-
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
84-
with:
85-
repository-url: https://test.pypi.org/legacy/
86-
skip-existing: true
87-
attestations: ${{ env.DRY_RUN }}
8882
- name: Publish package distributions to PyPI
8983
if: startsWith(env.DRY_RUN, 'false')
9084
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1

.github/workflows/test-python.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ jobs:
5353
- name: Run synchro smoke test
5454
run: |
5555
uv run --extra test --with unasync pytest tools/test_synchro.py -v --override-ini="addopts="
56+
- name: Validate pyproject.toml
57+
run: |
58+
uvx --from 'validate-pyproject[all]' validate-pyproject pyproject.toml
59+
- name: Check package metadata
60+
run: |
61+
uv build
62+
uvx twine check --strict dist/*
5663
- run: |
5764
sudo apt-get install -y cppcheck
5865
- run: |

test/asynchronous/test_logger.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from bson import json_util
2020
from pymongo.errors import OperationFailure
21-
from pymongo.logger import _DEFAULT_DOCUMENT_LENGTH
21+
from pymongo.logger import _DEFAULT_DOCUMENT_LENGTH, _CommandStatusMessage
2222
from test import unittest
2323
from test.asynchronous import AsyncIntegrationTest, async_client_context
2424

@@ -27,6 +27,15 @@
2727

2828
# https://github.com/mongodb/specifications/tree/master/source/command-logging-and-monitoring/tests#prose-tests
2929
class TestLogger(AsyncIntegrationTest):
30+
def _get_command_log(self, records, command_name, status):
31+
# PyPy's GC is non-deterministic, so cleanup commands from earlier tests can pollute the logs,
32+
# filter for the specific command and status we want
33+
for record in records:
34+
log = json_util.loads(record.getMessage())
35+
if log.get("commandName") == command_name and log.get("message") == status:
36+
return log
37+
self.fail(f"no {status!r} log found for command {command_name!r}")
38+
3039
async def test_default_truncation_limit(self):
3140
docs = [{"x": "y"} for _ in range(100)]
3241
db = self.db
@@ -36,15 +45,21 @@ async def test_default_truncation_limit(self):
3645
with self.assertLogs("pymongo.command", level="DEBUG") as cm:
3746
await db.test.insert_many(docs)
3847

39-
cmd_started_log = json_util.loads(cm.records[0].getMessage())
48+
cmd_started_log = self._get_command_log(
49+
cm.records, "insert", _CommandStatusMessage.STARTED
50+
)
4051
self.assertEqual(len(cmd_started_log["command"]), _DEFAULT_DOCUMENT_LENGTH + 3)
4152

42-
cmd_succeeded_log = json_util.loads(cm.records[1].getMessage())
53+
cmd_succeeded_log = self._get_command_log(
54+
cm.records, "insert", _CommandStatusMessage.SUCCEEDED
55+
)
4356
self.assertLessEqual(len(cmd_succeeded_log["reply"]), _DEFAULT_DOCUMENT_LENGTH + 3)
4457

4558
with self.assertLogs("pymongo.command", level="DEBUG") as cm:
4659
await db.test.find({}).to_list()
47-
cmd_succeeded_log = json_util.loads(cm.records[1].getMessage())
60+
cmd_succeeded_log = self._get_command_log(
61+
cm.records, "find", _CommandStatusMessage.SUCCEEDED
62+
)
4863
self.assertEqual(len(cmd_succeeded_log["reply"]), _DEFAULT_DOCUMENT_LENGTH + 3)
4964

5065
async def test_configured_truncation_limit(self):
@@ -54,14 +69,20 @@ async def test_configured_truncation_limit(self):
5469
with self.assertLogs("pymongo.command", level="DEBUG") as cm:
5570
await db.command(cmd)
5671

57-
cmd_started_log = json_util.loads(cm.records[0].getMessage())
72+
cmd_started_log = self._get_command_log(
73+
cm.records, "hello", _CommandStatusMessage.STARTED
74+
)
5875
self.assertEqual(len(cmd_started_log["command"]), 5 + 3)
5976

60-
cmd_succeeded_log = json_util.loads(cm.records[1].getMessage())
77+
cmd_succeeded_log = self._get_command_log(
78+
cm.records, "hello", _CommandStatusMessage.SUCCEEDED
79+
)
6180
self.assertLessEqual(len(cmd_succeeded_log["reply"]), 5 + 3)
6281
with self.assertRaises(OperationFailure):
6382
await db.command({"notARealCommand": True})
64-
cmd_failed_log = json_util.loads(cm.records[-1].getMessage())
83+
cmd_failed_log = self._get_command_log(
84+
cm.records, "notARealCommand", _CommandStatusMessage.FAILED
85+
)
6586
self.assertEqual(len(cmd_failed_log["failure"]), 5 + 3)
6687

6788
async def test_truncation_multi_byte_codepoints(self):
@@ -77,7 +98,9 @@ async def test_truncation_multi_byte_codepoints(self):
7798
with patch.dict("os.environ", {"MONGOB_LOG_MAX_DOCUMENT_LENGTH": length}):
7899
with self.assertLogs("pymongo.command", level="DEBUG") as cm:
79100
await self.db.test.insert_one({"x": multi_byte_char_str})
80-
cmd_started_log = json_util.loads(cm.records[0].getMessage())["command"]
101+
cmd_started_log = self._get_command_log(
102+
cm.records, "insert", _CommandStatusMessage.STARTED
103+
)["command"]
81104

82105
cmd_started_log = cmd_started_log[:-3]
83106
last_3_bytes = cmd_started_log.encode()[-3:].decode()

test/asynchronous/test_srv_polling.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
import time
2222
from typing import Any
2323

24-
from test.asynchronous.utils import flaky
2524
from test.utils_shared import FunctionCallRecorder
2625

2726
sys.path[0:0] = [""]
@@ -226,7 +225,6 @@ def response_callback(*args):
226225

227226
await self.run_scenario(response_callback, False)
228227

229-
@flaky(reason="PYTHON-5500", max_runs=3)
230228
async def test_dns_failures_logging(self):
231229
from dns import exception
232230

@@ -237,8 +235,10 @@ def response_callback(*args):
237235

238236
await self.run_scenario(response_callback, False)
239237

240-
srv_failure_logs = [r for r in cm.records if "SRV monitor check failed" in r.getMessage()]
241-
self.assertEqual(len(srv_failure_logs), 1)
238+
await async_wait_until(
239+
lambda: any("SRV monitor check failed" in r.getMessage() for r in cm.records),
240+
"log the SRV monitor check failure",
241+
)
242242

243243
async def test_dns_record_lookup_empty(self):
244244
response: list = []
@@ -270,14 +270,12 @@ def final_callback():
270270
# Nodelist should reflect new valid DNS resolver response.
271271
await self.assert_nodelist_change(response_final, client)
272272

273-
@flaky(reason="PYTHON-5315")
274273
async def test_recover_from_initially_empty_seedlist(self):
275274
def empty_seedlist():
276275
return []
277276

278277
await self._test_recover_from_initial(empty_seedlist)
279278

280-
@flaky(reason="PYTHON-5315")
281279
async def test_recover_from_initially_erroring_seedlist(self):
282280
def erroring_seedlist():
283281
raise ConfigurationError

test/test_logger.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,23 @@
1818

1919
from bson import json_util
2020
from pymongo.errors import OperationFailure
21-
from pymongo.logger import _DEFAULT_DOCUMENT_LENGTH
21+
from pymongo.logger import _DEFAULT_DOCUMENT_LENGTH, _CommandStatusMessage
2222
from test import IntegrationTest, client_context, unittest
2323

2424
_IS_SYNC = True
2525

2626

2727
# https://github.com/mongodb/specifications/tree/master/source/command-logging-and-monitoring/tests#prose-tests
2828
class TestLogger(IntegrationTest):
29+
def _get_command_log(self, records, command_name, status):
30+
# PyPy's GC is non-deterministic, so cleanup commands from earlier tests can pollute the logs,
31+
# filter for the specific command and status we want
32+
for record in records:
33+
log = json_util.loads(record.getMessage())
34+
if log.get("commandName") == command_name and log.get("message") == status:
35+
return log
36+
self.fail(f"no {status!r} log found for command {command_name!r}")
37+
2938
def test_default_truncation_limit(self):
3039
docs = [{"x": "y"} for _ in range(100)]
3140
db = self.db
@@ -35,15 +44,21 @@ def test_default_truncation_limit(self):
3544
with self.assertLogs("pymongo.command", level="DEBUG") as cm:
3645
db.test.insert_many(docs)
3746

38-
cmd_started_log = json_util.loads(cm.records[0].getMessage())
47+
cmd_started_log = self._get_command_log(
48+
cm.records, "insert", _CommandStatusMessage.STARTED
49+
)
3950
self.assertEqual(len(cmd_started_log["command"]), _DEFAULT_DOCUMENT_LENGTH + 3)
4051

41-
cmd_succeeded_log = json_util.loads(cm.records[1].getMessage())
52+
cmd_succeeded_log = self._get_command_log(
53+
cm.records, "insert", _CommandStatusMessage.SUCCEEDED
54+
)
4255
self.assertLessEqual(len(cmd_succeeded_log["reply"]), _DEFAULT_DOCUMENT_LENGTH + 3)
4356

4457
with self.assertLogs("pymongo.command", level="DEBUG") as cm:
4558
db.test.find({}).to_list()
46-
cmd_succeeded_log = json_util.loads(cm.records[1].getMessage())
59+
cmd_succeeded_log = self._get_command_log(
60+
cm.records, "find", _CommandStatusMessage.SUCCEEDED
61+
)
4762
self.assertEqual(len(cmd_succeeded_log["reply"]), _DEFAULT_DOCUMENT_LENGTH + 3)
4863

4964
def test_configured_truncation_limit(self):
@@ -53,14 +68,20 @@ def test_configured_truncation_limit(self):
5368
with self.assertLogs("pymongo.command", level="DEBUG") as cm:
5469
db.command(cmd)
5570

56-
cmd_started_log = json_util.loads(cm.records[0].getMessage())
71+
cmd_started_log = self._get_command_log(
72+
cm.records, "hello", _CommandStatusMessage.STARTED
73+
)
5774
self.assertEqual(len(cmd_started_log["command"]), 5 + 3)
5875

59-
cmd_succeeded_log = json_util.loads(cm.records[1].getMessage())
76+
cmd_succeeded_log = self._get_command_log(
77+
cm.records, "hello", _CommandStatusMessage.SUCCEEDED
78+
)
6079
self.assertLessEqual(len(cmd_succeeded_log["reply"]), 5 + 3)
6180
with self.assertRaises(OperationFailure):
6281
db.command({"notARealCommand": True})
63-
cmd_failed_log = json_util.loads(cm.records[-1].getMessage())
82+
cmd_failed_log = self._get_command_log(
83+
cm.records, "notARealCommand", _CommandStatusMessage.FAILED
84+
)
6485
self.assertEqual(len(cmd_failed_log["failure"]), 5 + 3)
6586

6687
def test_truncation_multi_byte_codepoints(self):
@@ -76,7 +97,9 @@ def test_truncation_multi_byte_codepoints(self):
7697
with patch.dict("os.environ", {"MONGOB_LOG_MAX_DOCUMENT_LENGTH": length}):
7798
with self.assertLogs("pymongo.command", level="DEBUG") as cm:
7899
self.db.test.insert_one({"x": multi_byte_char_str})
79-
cmd_started_log = json_util.loads(cm.records[0].getMessage())["command"]
100+
cmd_started_log = self._get_command_log(
101+
cm.records, "insert", _CommandStatusMessage.STARTED
102+
)["command"]
80103

81104
cmd_started_log = cmd_started_log[:-3]
82105
last_3_bytes = cmd_started_log.encode()[-3:].decode()

test/test_srv_polling.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
import time
2222
from typing import Any
2323

24-
from test.utils import flaky
2524
from test.utils_shared import FunctionCallRecorder
2625

2726
sys.path[0:0] = [""]
@@ -226,7 +225,6 @@ def response_callback(*args):
226225

227226
self.run_scenario(response_callback, False)
228227

229-
@flaky(reason="PYTHON-5500", max_runs=3)
230228
def test_dns_failures_logging(self):
231229
from dns import exception
232230

@@ -237,8 +235,10 @@ def response_callback(*args):
237235

238236
self.run_scenario(response_callback, False)
239237

240-
srv_failure_logs = [r for r in cm.records if "SRV monitor check failed" in r.getMessage()]
241-
self.assertEqual(len(srv_failure_logs), 1)
238+
wait_until(
239+
lambda: any("SRV monitor check failed" in r.getMessage() for r in cm.records),
240+
"log the SRV monitor check failure",
241+
)
242242

243243
def test_dns_record_lookup_empty(self):
244244
response: list = []
@@ -270,14 +270,12 @@ def final_callback():
270270
# Nodelist should reflect new valid DNS resolver response.
271271
self.assert_nodelist_change(response_final, client)
272272

273-
@flaky(reason="PYTHON-5315")
274273
def test_recover_from_initially_empty_seedlist(self):
275274
def empty_seedlist():
276275
return []
277276

278277
self._test_recover_from_initial(empty_seedlist)
279278

280-
@flaky(reason="PYTHON-5315")
281279
def test_recover_from_initially_erroring_seedlist(self):
282280
def erroring_seedlist():
283281
raise ConfigurationError

test/utils_shared.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,19 +338,38 @@ def __eq__(self, other):
338338

339339

340340
class FunctionCallRecorder:
341-
"""Utility class to wrap a callable and record its invocations."""
341+
"""Utility class to wrap a callable and record its invocations.
342+
343+
A synchronous callable is recorded immediately, before it runs. A
344+
coroutine function is recorded only once the returned coroutine
345+
finishes running, whether it returns, raises, or is cancelled, so
346+
callers can rely on a recorded call meaning the wrapped coroutine
347+
actually ran rather than merely having been scheduled.
348+
"""
342349

343350
def __init__(self, function):
344351
self._function = function
345352
self._call_list = []
346353

347354
def __call__(self, *args, **kwargs):
348-
self._call_list.append((args, kwargs))
349355
if iscoroutinefunction(self._function):
350-
return self._function(*args, **kwargs)
356+
357+
async def _run_and_record():
358+
try:
359+
return await self._function(*args, **kwargs)
360+
finally:
361+
self._call_list.append((args, kwargs))
362+
363+
return _run_and_record()
351364
else:
365+
self._call_list.append((args, kwargs))
352366
return self._function(*args, **kwargs)
353367

368+
def __get__(self, obj, objtype=None):
369+
if obj is None:
370+
return self
371+
return functools.partial(self, obj)
372+
354373
def reset(self):
355374
"""Wipes the call list."""
356375
self._call_list = []

0 commit comments

Comments
 (0)