Skip to content

Commit 71a240c

Browse files
committed
Copilot + Noah review
1 parent 0b948d8 commit 71a240c

4 files changed

Lines changed: 113 additions & 159 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright 2026-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+
15+
"""Async-only unit tests for AsyncPeriodicExecutor."""
16+
17+
from __future__ import annotations
18+
19+
import sys
20+
21+
sys.path[0:0] = [""]
22+
23+
from test.asynchronous import AsyncUnitTest, unittest
24+
25+
from pymongo.periodic_executor import AsyncPeriodicExecutor
26+
27+
28+
class TestAsyncPeriodicExecutorExceptions(AsyncUnitTest):
29+
async def test_target_exception_stops_executor(self):
30+
call_count = 0
31+
32+
async def target():
33+
nonlocal call_count
34+
call_count += 1
35+
raise RuntimeError("error")
36+
37+
executor = AsyncPeriodicExecutor(
38+
interval=30.0, min_interval=0.01, target=target, name="test"
39+
)
40+
executor.open()
41+
await executor.join(timeout=2)
42+
if executor._task is not None and executor._task.done():
43+
executor._task.exception()
44+
self.assertEqual(call_count, 1, "target should stop after exception")
45+
46+
47+
if __name__ == "__main__":
48+
unittest.main()

test/asynchronous/test_periodic_executor.py

Lines changed: 31 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -30,38 +30,27 @@
3030
_IS_SYNC = False
3131

3232

33-
def _make_executor(interval=30.0, min_interval=0.01, target=None, name="test"):
34-
if target is None:
33+
class TestAsyncPeriodicExecutor(AsyncUnitTest):
34+
def _make_executor(self, interval=30.0, min_interval=0.01, target=None, name="test"):
35+
if target is None:
3536

36-
async def target():
37-
return True
38-
39-
return AsyncPeriodicExecutor(
40-
interval=interval, min_interval=min_interval, target=target, name=name
41-
)
42-
43-
44-
class AsyncPeriodicExecutorTestBase(AsyncUnitTest):
45-
async def asyncSetUp(self):
46-
self.executor = None
47-
48-
async def asyncTearDown(self):
49-
if self.executor is not None:
50-
self.executor.close()
51-
await self.executor.join(timeout=2)
37+
async def target():
38+
return True
5239

40+
executor = AsyncPeriodicExecutor(
41+
interval=interval, min_interval=min_interval, target=target, name=name
42+
)
43+
self.addAsyncCleanup(self._close_executor, executor)
44+
return executor
5345

54-
class TestAsyncPeriodicExecutor(AsyncPeriodicExecutorTestBase):
55-
async def test_repr_contains_class_and_name(self):
56-
executor = _make_executor(name="exec")
57-
executor_repr = repr(executor)
58-
self.assertIn("AsyncPeriodicExecutor", executor_repr)
59-
self.assertIn("exec", executor_repr)
46+
async def _close_executor(self, executor):
47+
executor.close()
48+
await executor.join(timeout=2)
6049

6150
async def test_join_without_open_is_safe(self):
62-
self.executor = _make_executor()
51+
executor = self._make_executor()
6352
try:
64-
await self.executor.join(timeout=0.01)
53+
await executor.join(timeout=0.01)
6554
except Exception as e:
6655
self.fail(f"join() raised unexpected Exception {e}")
6756

@@ -75,48 +64,11 @@ async def target():
7564
ran.set()
7665
return False
7766

78-
self.executor = _make_executor(target=target)
79-
self.executor.open()
80-
await self.executor.join(timeout=2)
67+
executor = self._make_executor(target=target)
68+
executor.open()
69+
await executor.join(timeout=2)
8170
self.assertTrue(ran.is_set(), "target never ran")
8271

83-
async def test_target_exception_stops_executor(self):
84-
if _IS_SYNC:
85-
ran = threading.Event()
86-
captured_exc: list = []
87-
orig_excepthook = threading.excepthook
88-
89-
def _capture_excepthook(args):
90-
captured_exc.append(args.exc_value)
91-
92-
threading.excepthook = _capture_excepthook
93-
try:
94-
95-
def target():
96-
ran.set()
97-
raise RuntimeError("error")
98-
99-
self.executor = _make_executor(target=target)
100-
self.executor.open()
101-
self.executor.join(timeout=2)
102-
self.assertTrue(ran.is_set(), "target never ran")
103-
finally:
104-
threading.excepthook = orig_excepthook
105-
self.assertEqual(len(captured_exc), 1)
106-
self.assertIsInstance(captured_exc[0], RuntimeError)
107-
else:
108-
call_count = 0
109-
110-
async def target():
111-
nonlocal call_count
112-
call_count += 1
113-
raise RuntimeError("error")
114-
115-
self.executor = _make_executor(target=target)
116-
self.executor.open()
117-
await self.executor.join(timeout=2)
118-
self.assertEqual(call_count, 1, "target should stop after exception")
119-
12072
async def test_skip_sleep_flag_skips_interval(self):
12173
call_times = []
12274

@@ -126,10 +78,10 @@ async def target():
12678
return False
12779
return True
12880

129-
self.executor = _make_executor(interval=30.0, min_interval=0.001, target=target)
130-
self.executor.skip_sleep()
131-
self.executor.open()
132-
await self.executor.join(timeout=3)
81+
executor = self._make_executor(interval=30.0, min_interval=0.001, target=target)
82+
executor.skip_sleep()
83+
executor.open()
84+
await executor.join(timeout=3)
13385
self.assertGreaterEqual(len(call_times), 2)
13486
self.assertLess(call_times[1] - call_times[0], 5.0)
13587

@@ -147,15 +99,15 @@ async def target():
14799
woken.set()
148100
return call_count < 2
149101

150-
self.executor = _make_executor(interval=30.0, min_interval=0.01, target=target)
151-
self.executor.open()
102+
executor = self._make_executor(interval=30.0, min_interval=0.01, target=target)
103+
executor.open()
152104
if _IS_SYNC:
153105
woken.wait(timeout=2)
154106
else:
155107
assert isinstance(woken, asyncio.Event)
156108
await asyncio.wait_for(woken.wait(), timeout=2)
157-
self.executor.wake()
158-
await self.executor.join(timeout=3)
109+
executor.wake()
110+
await executor.join(timeout=3)
159111
self.assertGreaterEqual(call_count, 2)
160112

161113
async def test_open_after_target_returns_false(self):
@@ -166,11 +118,11 @@ async def target():
166118
called += 1
167119
return False
168120

169-
self.executor = _make_executor(target=target)
170-
self.executor.open()
171-
await self.executor.join(timeout=2)
172-
self.executor.open()
173-
await self.executor.join(timeout=2)
121+
executor = self._make_executor(target=target)
122+
executor.open()
123+
await executor.join(timeout=2)
124+
executor.open()
125+
await executor.join(timeout=2)
174126
self.assertGreaterEqual(called, 2)
175127

176128

test/test_periodic_executor.py

Lines changed: 33 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -23,43 +23,34 @@
2323

2424
sys.path[0:0] = [""]
2525

26-
from test import UnitTest, unittest
26+
from test.synchronous import UnitTest, unittest
2727

2828
from pymongo.periodic_executor import PeriodicExecutor
2929

30-
_IS_SYNC = True
30+
_IS_SYNC = False
3131

3232

33-
def _make_executor(interval=30.0, min_interval=0.01, target=None, name="test"):
34-
if target is None:
33+
class TestAsyncPeriodicExecutor(UnitTest):
34+
def _make_executor(self, interval=30.0, min_interval=0.01, target=None, name="test"):
35+
if target is None:
3536

36-
def target():
37-
return True
38-
39-
return PeriodicExecutor(interval=interval, min_interval=min_interval, target=target, name=name)
40-
41-
42-
class PeriodicExecutorTestBase(UnitTest):
43-
def setUp(self):
44-
self.executor = None
45-
46-
def tearDown(self):
47-
if self.executor is not None:
48-
self.executor.close()
49-
self.executor.join(timeout=2)
37+
def target():
38+
return True
5039

40+
executor = PeriodicExecutor(
41+
interval=interval, min_interval=min_interval, target=target, name=name
42+
)
43+
self.addCleanup(self._close_executor, executor)
44+
return executor
5145

52-
class TestPeriodicExecutor(PeriodicExecutorTestBase):
53-
def test_repr_contains_class_and_name(self):
54-
executor = _make_executor(name="exec")
55-
executor_repr = repr(executor)
56-
self.assertIn("PeriodicExecutor", executor_repr)
57-
self.assertIn("exec", executor_repr)
46+
def _close_executor(self, executor):
47+
executor.close()
48+
executor.join(timeout=2)
5849

5950
def test_join_without_open_is_safe(self):
60-
self.executor = _make_executor()
51+
executor = self._make_executor()
6152
try:
62-
self.executor.join(timeout=0.01)
53+
executor.join(timeout=0.01)
6354
except Exception as e:
6455
self.fail(f"join() raised unexpected Exception {e}")
6556

@@ -73,48 +64,11 @@ def target():
7364
ran.set()
7465
return False
7566

76-
self.executor = _make_executor(target=target)
77-
self.executor.open()
78-
self.executor.join(timeout=2)
67+
executor = self._make_executor(target=target)
68+
executor.open()
69+
executor.join(timeout=2)
7970
self.assertTrue(ran.is_set(), "target never ran")
8071

81-
def test_target_exception_stops_executor(self):
82-
if _IS_SYNC:
83-
ran = threading.Event()
84-
captured_exc: list = []
85-
orig_excepthook = threading.excepthook
86-
87-
def _capture_excepthook(args):
88-
captured_exc.append(args.exc_value)
89-
90-
threading.excepthook = _capture_excepthook
91-
try:
92-
93-
def target():
94-
ran.set()
95-
raise RuntimeError("error")
96-
97-
self.executor = _make_executor(target=target)
98-
self.executor.open()
99-
self.executor.join(timeout=2)
100-
self.assertTrue(ran.is_set(), "target never ran")
101-
finally:
102-
threading.excepthook = orig_excepthook
103-
self.assertEqual(len(captured_exc), 1)
104-
self.assertIsInstance(captured_exc[0], RuntimeError)
105-
else:
106-
call_count = 0
107-
108-
def target():
109-
nonlocal call_count
110-
call_count += 1
111-
raise RuntimeError("error")
112-
113-
self.executor = _make_executor(target=target)
114-
self.executor.open()
115-
self.executor.join(timeout=2)
116-
self.assertEqual(call_count, 1, "target should stop after exception")
117-
11872
def test_skip_sleep_flag_skips_interval(self):
11973
call_times = []
12074

@@ -124,10 +78,10 @@ def target():
12478
return False
12579
return True
12680

127-
self.executor = _make_executor(interval=30.0, min_interval=0.001, target=target)
128-
self.executor.skip_sleep()
129-
self.executor.open()
130-
self.executor.join(timeout=3)
81+
executor = self._make_executor(interval=30.0, min_interval=0.001, target=target)
82+
executor.skip_sleep()
83+
executor.open()
84+
executor.join(timeout=3)
13185
self.assertGreaterEqual(len(call_times), 2)
13286
self.assertLess(call_times[1] - call_times[0], 5.0)
13387

@@ -145,15 +99,15 @@ def target():
14599
woken.set()
146100
return call_count < 2
147101

148-
self.executor = _make_executor(interval=30.0, min_interval=0.01, target=target)
149-
self.executor.open()
102+
executor = self._make_executor(interval=30.0, min_interval=0.01, target=target)
103+
executor.open()
150104
if _IS_SYNC:
151105
woken.wait(timeout=2)
152106
else:
153107
assert isinstance(woken, asyncio.Event)
154108
asyncio.wait_for(woken.wait(), timeout=2)
155-
self.executor.wake()
156-
self.executor.join(timeout=3)
109+
executor.wake()
110+
executor.join(timeout=3)
157111
self.assertGreaterEqual(call_count, 2)
158112

159113
def test_open_after_target_returns_false(self):
@@ -164,11 +118,11 @@ def target():
164118
called += 1
165119
return False
166120

167-
self.executor = _make_executor(target=target)
168-
self.executor.open()
169-
self.executor.join(timeout=2)
170-
self.executor.open()
171-
self.executor.join(timeout=2)
121+
executor = self._make_executor(target=target)
122+
executor.open()
123+
executor.join(timeout=2)
124+
executor.open()
125+
executor.join(timeout=2)
172126
self.assertGreaterEqual(called, 2)
173127

174128

tools/synchro.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ def async_only_test(f: str) -> bool:
190190
"test_async_loop_safety.py",
191191
"test_async_contextvars_reset.py",
192192
"test_async_loop_unblocked.py",
193+
"test_async_periodic_executor.py",
193194
]
194195

195196

@@ -252,7 +253,6 @@ def async_only_test(f: str) -> bool:
252253
"test_monitoring.py",
253254
"test_mongos_load_balancing.py",
254255
"test_on_demand_csfle.py",
255-
"test_periodic_executor.py",
256256
"test_pooling.py",
257257
"test_raw_bson.py",
258258
"test_read_concern.py",

0 commit comments

Comments
 (0)