PYTHON-5784 Coverage increase for periodic_executor.py#2771
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a dedicated unit test module for pymongo.periodic_executor, targeting both the threading-based PeriodicExecutor and asyncio-based AsyncPeriodicExecutor, plus module-level executor registration/shutdown helpers, to improve overall coverage.
Changes:
- Adds new unit tests for
PeriodicExecutorlifecycle and control methods (open/close/join/wake/update_interval/skip_sleep, stop conditions). - Adds new unit tests for
AsyncPeriodicExecutorlifecycle and stop conditions. - Adds unit tests for module-level
_register_executor()and_shutdown_executors()behavior.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| async def _default_target(): | ||
| return True | ||
|
|
||
| if target is None: | ||
| target = _default_target |
There was a problem hiding this comment.
Does this need to differ from the way _make_sync above does it?
| def _run(coroutine): | ||
| return asyncio.run(coroutine) |
There was a problem hiding this comment.
The individual tests that use asynchronous methods should use AsyncUnitTest or another appropriate test class from test/asynchronous/__init__.py instead of this. Each asyncio.run() call adds a significant amount of runtime overhead to setup and teardown the event loop.
|
|
||
| class TestPeriodicExecutorRepr(unittest.TestCase): | ||
| def test_repr_contains_class_and_name(self): | ||
| ex = _make_sync(name="myexec") |
There was a problem hiding this comment.
We can move all the _make_sync() calls into an asyncSetUp/setUp method on a base class shared by all of the test classes here.
| finally: | ||
| ex.close() | ||
| ex.join(timeout=2) | ||
|
|
There was a problem hiding this comment.
Similarly, safe closing of each executor can be done in an asyncTearDown/tearDown method on a base class.
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| class TestAsyncPeriodicExecutorRepr(unittest.TestCase): |
There was a problem hiding this comment.
Can we move the AsyncPeriodicExecutor tests into test/asynchronous/ and generate the synchronous ones using synchro? That would half the amount of code we need to maintain.
There was a problem hiding this comment.
Yes. The only tests that should not go through synchro are tests that don't need modification between async and sync, as well as tests against common modules shared between them.
| await self.executor.join(timeout=2) | ||
|
|
||
|
|
||
| class TestAsyncPeriodicExecutorRepr(AsyncUnitTest): |
There was a problem hiding this comment.
This class should also inherit from AsyncPeriodicExecutorTestBase and use its setup/teardown methods.
| self.assertIn("exec", executor_repr) | ||
|
|
||
|
|
||
| class TestAsyncPeriodicExecutorBasic(AsyncPeriodicExecutorTestBase): |
There was a problem hiding this comment.
What's the intended purpose of having so many separate classes, each with a couple of tests? We generally only separate tests inside files into separate classes if the groupings need different infrastructure (unit vs integration) or specific setup/configuration common to them.
There was a problem hiding this comment.
This question still stands @aclark4life.
No good reason, especially in the context of removing non-behavior-checking tests. I'll push a single class (plus base class) unless we come across a reason to add more.
|
|
||
|
|
||
| class TestAsyncPeriodicExecutorBasic(AsyncPeriodicExecutorTestBase): | ||
| async def test_update_interval(self): |
There was a problem hiding this comment.
This test and the following don't test behavior but implementation.
|
|
||
|
|
||
| class TestAsyncPeriodicExecutorLifecycle(AsyncPeriodicExecutorTestBase): | ||
| async def test_open_starts_worker(self): |
There was a problem hiding this comment.
Same here, this is testing implementation, not behavior.
| else: | ||
| self.assertIsNotNone(self.executor._task) | ||
|
|
||
| async def test_close_sets_stopped(self): |
| self.executor = _make_executor(target=target) | ||
| self.executor.open() | ||
| if _IS_SYNC: | ||
| self.assertTrue(ran.wait(timeout=2), "target never ran") |
There was a problem hiding this comment.
Is this needed if we're calling join() with the same timeout immediately after? We could check that ran is set after the join, which would mean that the executor stopped running or else the join would timeout.
| else: | ||
| await asyncio.wait_for(ran.wait(), timeout=2) | ||
| await self.executor.join(timeout=2) | ||
| self.assertTrue(self.executor._stopped) |
There was a problem hiding this comment.
We can remove this if my above comment applies.
|
|
||
| self.executor = _make_executor(target=target) | ||
| self.executor.open() | ||
| self.assertTrue(ran.wait(timeout=2), "target never ran") |
|
|
||
| self.executor = _make_executor(target=target) | ||
| self.executor.open() | ||
| await asyncio.wait_for(ran.wait(), timeout=2) |
| call_times = [] | ||
|
|
||
| async def target(): | ||
| call_times.append(time.monotonic() if _IS_SYNC else asyncio.get_running_loop().time()) |
There was a problem hiding this comment.
Why use asyncio.get_running_loop().time() over time.monotonic()? Asyncio's default event loop returns time.monotonic() anyway: https://github.com/python/cpython/blob/b8ebd078f90007d48fcab85effadb33769cd080c/Lib/asyncio/base_events.py#L771.
| self.assertGreaterEqual(called[0], 2) | ||
|
|
||
|
|
||
| class TestShouldStop(AsyncUnitTest): |
There was a problem hiding this comment.
Both of these tests are again testing implementation, not behavior. Do they add confidence in the behavior of PeriodicExecutor when used?
| self.assertTrue(executor._thread_will_exit) | ||
|
|
||
|
|
||
| class TestRegisterExecutor(AsyncUnitTest): |
There was a problem hiding this comment.
Does this test meaningfully add coverage that our existingtest_cleanup_executors_on_client_del in test/test_monitor.py does not?
| ) | ||
|
|
||
|
|
||
| class AsyncPeriodicExecutorTestBase(AsyncUnitTest): |
There was a problem hiding this comment.
Every test except for test_join_without_open_is_safe now explicitly calls _make_executor() and .join() as part of execution, making this base class largely unused.
| finally: | ||
| threading.excepthook = orig_excepthook | ||
| self.assertEqual(len(captured_exc), 1) | ||
| self.assertIsInstance(captured_exc[0], RuntimeError) |
There was a problem hiding this comment.
Can we instead check that target is called only once due to an exception being thrown? That would be equivalent here and require less code.
| self.assertLess(call_times[1] - call_times[0], 5.0) | ||
|
|
||
| async def test_wake_causes_early_run(self): | ||
| call_count = [0] |
There was a problem hiding this comment.
This can be a plain integer if we use nonlocal in target.
There was a problem hiding this comment.
TIL nonlocal exists.
| await self.executor.join(timeout=3) | ||
| self.assertGreaterEqual(call_count[0], 2) | ||
|
|
||
| async def test_open_after_target_returns_false(self): |
There was a problem hiding this comment.
Same here as above, prefer nonlocal over the list trick.
b6846db to
5dbfb54
Compare
6fa5365 to
39cf1f4
Compare
periodic_executor.py
| def async_only_test(f: str) -> bool: | ||
| """Return True for async tests that should not be converted to sync.""" | ||
| return f in [ | ||
| "test_locks.py", | ||
| "test_concurrency.py", | ||
| "test_async_cancellation.py", | ||
| "test_async_loop_safety.py", | ||
| "test_async_contextvars_reset.py", | ||
| "test_async_loop_unblocked.py", | ||
| "test_async_periodic_executor.py", | ||
| ] |
|
|
||
| sys.path[0:0] = [""] | ||
|
|
||
| from test.synchronous import UnitTest, unittest |
|
|
||
| from pymongo.periodic_executor import PeriodicExecutor | ||
|
|
||
| _IS_SYNC = False |
| _IS_SYNC = False | ||
|
|
||
|
|
||
| class TestAsyncPeriodicExecutor(UnitTest): |
| def test_target_returning_false_stops_executor(self): | ||
| if _IS_SYNC: | ||
| ran = threading.Event() | ||
| else: | ||
| ran = asyncio.Event() | ||
|
|
||
| def target(): | ||
| ran.set() | ||
| return False | ||
|
|
||
| executor = self._make_executor(target=target) | ||
| executor.open() | ||
| executor.join(timeout=2) | ||
| self.assertTrue(ran.is_set(), "target never ran") | ||
|
|
4160761 to
6936ea8
Compare
| self.assertGreaterEqual(call_count, 2) | ||
|
|
||
| async def test_update_interval_changes_next_wait(self): | ||
| call_times = [] |
There was a problem hiding this comment.
This call site needs to be updated to use nonlocal as well
…cated API, gc-safe weakref assertion, non-blocking shutdown test, retrieve task exception
…ous/ via synchro - Create test/asynchronous/test_periodic_executor.py as the single source of truth for all periodic executor tests, using AsyncUnitTest with asyncSetUp/ asyncTearDown base class for executor lifecycle management - Register test_periodic_executor.py in synchro's converted_tests so the sync variant is auto-generated - Replace the manually-maintained test/test_periodic_executor.py with the synchro-generated equivalent, eliminating duplicated async/sync test code - Use _IS_SYNC branching for the small number of tests that differ between threading (PeriodicExecutor) and asyncio (AsyncPeriodicExecutor) behavior
- Replace pe_module alias with periodic_executor - Remove underscore from base test class name - Alpha sort test_periodic_executor.py in synchro list - Rename ex to executor in tests - Rename r to executor_repr in repr test - Replace boom with error in exception messages
The synchronous test was generated but not post-processed (still had 'from test.synchronous import', _IS_SYNC=False, and the async class name) because test_periodic_executor.py was missing from converted_tests. Add it and regenerate so the sync file flips _IS_SYNC=True, fixes its imports, and renames the class to TestPeriodicExecutor.
Add test_update_interval_changes_next_wait: shorten the interval during the first run and assert the next run happens promptly (rather than after the original 30s), exercising update_interval() on both executors.
update_interval() stores into _interval (float) and is called with float values, but was annotated new_interval: int. Correct the annotation on both executors and use a small positive interval in the coverage test.
Consolidate the target-exception test into the synchro'd source so both AsyncPeriodicExecutor and the threaded PeriodicExecutor are covered from a single source, and delete the async-only test file. Why this reverses the async-only split from 3340959: that commit carved the exception test out into test_async_periodic_executor.py to dodge the noisy traceback an uncaught exception prints via threading.excepthook on the background thread. But the two _run loops handle the exception differently -- the threaded version additionally takes the lock and sets _thread_will_exit, which feeds the join-on-reopen path in open(). The async test therefore did not cover that sync-only branch, leaving a real gap (also flagged by Copilot). Rather than maintain two near-identical hand-written tests (which cuts against the synchro direction this PR has otherwise followed), the test now lives in the async source with an `if _IS_SYNC:` branch that swaps threading.excepthook to a no-op for the threaded case. Verified the generated sync test passes 15/15 with no flakiness or stderr noise, which confirms the path is testable and there was no real reason to keep it async-only.
PYTHON-5784
Changes in this PR
Adds
test/asynchronous/test_periodic_executor.py(synchro'd totest/test_periodic_executor.py) with unit tests forpymongo/periodic_executor.py, covering bothPeriodicExecutorandAsyncPeriodicExecutor:join()beforeopen()is a safe no-op.Falsestops the executor.skip_sleep()causes the next iteration to run without waiting the full interval.wake()interrupts the current sleep and triggers an early run.open()after the target returnedFalserestarts the executor.Test Plan
Added unit tests using
asyncio.Event/threading.Eventfor deterministic synchronization. The exception test swapsthreading.excepthookto a no-op so the intentionalRuntimeErrorin the worker thread is not surfaced by pytest's threadexception plugin (no-op for the async executor since no thread is involved).Checklist
Checklist for Author
Checklist for Reviewer