Python 4542 - Improved sessions API#2712
Conversation
| if session is not None: | ||
| session._process_response(reply) | ||
|
|
||
| def _get_bound_session(self) -> Optional[AsyncClientSession]: |
There was a problem hiding this comment.
This is encapsulated in a separate utility function because cursor operations call _ensure_session directly, while most other database operations call _tmp_session instead. Separating out this check keeps the clarity of the current behavioral differences with minimal code duplication.
|
|
||
| - Added the :meth:`~pymongo.asynchronous.client_session.AsyncClientSession.bind` and :meth:`~pymongo.client_session.ClientSession.bind` methods | ||
| that allow users to bind a session to all database operations within the scope of a context manager instead of having to explicitly pass the session to each individual operation. | ||
| See <PLACEHOLDER> for examples and more information. |
There was a problem hiding this comment.
<PLACEHOLDER> should be the MongoDB docs ?
There was a problem hiding this comment.
Yeah once we have examples and such added to a page I'll update these spots.
| self._attached_to_cursor = False | ||
| # Should we leave the session alive when the cursor is closed? | ||
| self._leave_alive = False | ||
| # Is this session bound to a scope? |
There was a problem hiding this comment.
Nit: I had to look up what "scope" means in this context so maybe "# Is this session bound to a context manager scope?"
| return bound_session.session | ||
| else: | ||
| raise InvalidOperation( | ||
| "Only the client that created the bound session can perform operations within its context block. See <PLACEHOLDER> for more information." |
There was a problem hiding this comment.
Another <PLACEHOLDER> here … assuming that maybe these come out after merge?
| def _ensure_session(self, session: Optional[ClientSession] = None) -> Optional[ClientSession]: | ||
| """If provided session is None, lend a temporary session.""" | ||
| """If provided session and bound session are None, lend a temporary session.""" | ||
| session = session or self._get_bound_session() |
There was a problem hiding this comment.
I don't understand what's going on here before the changes. How does:
if session:
return session
lend a temporary session?
There was a problem hiding this comment.
We call _ensure_session(session) in a bunch of places where session is an actual explicit session if the user passed one to the operation. The former if session: return session check is to return that explicit session if it exists instead of lending a temporary one.
| return bound_session.session | ||
| else: | ||
| raise InvalidOperation( | ||
| "Only the client that created the bound session can perform operations within its context block. See <PLACEHOLDER> for more information." |
There was a problem hiding this comment.
Third <PLACEHOLDER> maybe synchro'd
There was a problem hiding this comment.
Pull request overview
Adds a “bound session” mechanism to PyMongo’s explicit sessions so users can scope a ClientSession/AsyncClientSession to all operations within a context block (without passing session=... to every call).
Changes:
- Introduces
ClientSession.bind()/AsyncClientSession.bind()backed by aContextVar, and updatesMongoClient/AsyncMongoClientto use a bound session when no session is explicitly provided. - Expands sync/async session tests to cover bound-session behavior and nested binding.
- Updates changelog and synchro/unasync replacement mapping for the new internal bound-session helper type.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/synchro.py | Adds unasync replacement mapping for the new bound-session helper type. |
| pymongo/synchronous/client_session.py | Adds ContextVar-based bound-session support and ClientSession.bind(). |
| pymongo/synchronous/mongo_client.py | Uses bound session in _ensure_session/_tmp_session and adds _get_bound_session(). |
| pymongo/asynchronous/client_session.py | Adds ContextVar-based bound-session support and AsyncClientSession.bind(). |
| pymongo/asynchronous/mongo_client.py | Uses bound session in _ensure_session/_tmp_session and adds _get_bound_session(). |
| test/test_session.py | Adds coverage for bound sessions and nested binding (sync). |
| test/asynchronous/test_session.py | Adds coverage for bound sessions and nested binding (async). |
| doc/changelog.rst | Documents the new API in the changelog (currently with placeholders). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def bind(self) -> AsyncClientSession: | ||
| self._bound = True | ||
| return self | ||
|
|
There was a problem hiding this comment.
bind() mutates session state (self._bound = True) and returns self, which changes the meaning of using the session itself as an async context manager. In particular, async with client.start_session().bind() as s: (or calling s.bind() earlier and later doing async with s:) will no longer end the session on exit, leaking server sessions and breaking the long-standing contract documented in this module. Consider making bind() return a dedicated async context manager (separate object) that only sets/resets the bound-session ContextVar, while keeping AsyncClientSession.__aenter__/__aexit__ semantics unchanged (always ending the session).
| self, session: Optional[client_session.AsyncClientSession] | ||
| ) -> AsyncGenerator[Optional[client_session.AsyncClientSession], None]: | ||
| """If provided session is None, lend a temporary session.""" | ||
| if session is not None: | ||
| if not isinstance(session, client_session.AsyncClientSession): | ||
| raise ValueError( | ||
| f"'session' argument must be an AsyncClientSession or None, not {type(session)}" | ||
| ) | ||
| if session is not None and not isinstance(session, client_session.AsyncClientSession): | ||
| raise ValueError( | ||
| f"'session' argument must be an AsyncClientSession or None, not {type(session)}" | ||
| ) | ||
|
|
||
| # Check for a bound session. If one exists, treat it as an explicitly passed session. | ||
| session = session or self._get_bound_session() | ||
| if session: |
There was a problem hiding this comment.
The _tmp_session docstring now understates behavior: this async context manager also uses a bound session when one exists. Update the docstring to reflect that it will yield an explicitly provided session or the currently bound session, otherwise it will create an implicit temporary session.
| Changes in Version 4.17.0 (2026/XX/XX) | ||
| -------------------------------------- | ||
|
|
||
| PyMongo 4.17 brings a number of changes including: | ||
|
|
||
| - Added the :meth:`~pymongo.asynchronous.client_session.AsyncClientSession.bind` and :meth:`~pymongo.client_session.ClientSession.bind` methods | ||
| that allow users to bind a session to all database operations within the scope of a context manager instead of having to explicitly pass the session to each individual operation. | ||
| See <PLACEHOLDER> for examples and more information. |
There was a problem hiding this comment.
This changelog entry contains placeholders (2026/XX/XX and <PLACEHOLDER>). Before merging, replace these with the actual release date format used elsewhere in this file and a real documentation link (or remove the link sentence) so the changelog is publishable as-is.
| Changes in Version 4.17.0 (2026/XX/XX) | |
| -------------------------------------- | |
| PyMongo 4.17 brings a number of changes including: | |
| - Added the :meth:`~pymongo.asynchronous.client_session.AsyncClientSession.bind` and :meth:`~pymongo.client_session.ClientSession.bind` methods | |
| that allow users to bind a session to all database operations within the scope of a context manager instead of having to explicitly pass the session to each individual operation. | |
| See <PLACEHOLDER> for examples and more information. | |
| Changes in Version 4.17.0 (2026/02/26) | |
| -------------------------------------- | |
| PyMongo 4.17 brings a number of changes including: | |
| - Added the :meth:`~pymongo.asynchronous.client_session.AsyncClientSession.bind` and :meth:`~pymongo.client_session.ClientSession.bind` methods | |
| that allow users to bind a session to all database operations within the scope of a context manager instead of having to explicitly pass the session to each individual operation. |
| def bind(self) -> ClientSession: | ||
| self._bound = True | ||
| return self | ||
|
|
There was a problem hiding this comment.
bind() mutates session state (self._bound = True) and returns self, which changes the meaning of using the session itself as a context manager. In particular, with client.start_session().bind() as s: (or calling s.bind() earlier and later doing with s:) will no longer end the session on exit, leaking server sessions and breaking the long-standing contract documented in this module. Consider making bind() return a dedicated context manager (separate object) that only sets/resets the bound-session ContextVar, while keeping ClientSession.__enter__/__exit__ semantics unchanged (always ending the session).
| def bind(self) -> ClientSession: | |
| self._bound = True | |
| return self | |
| class _BindContext(ContextManager["ClientSession"]): | |
| """Context manager used by ClientSession.bind(). | |
| Temporarily marks the session as bound so that __enter__/__exit__ | |
| manage the bound-session ContextVar, and then restores the previous | |
| bound state on exit. | |
| """ | |
| def __init__(self, session: "ClientSession") -> None: | |
| self._session = session | |
| self._prev_bound = session._bound | |
| def __enter__(self) -> "ClientSession": | |
| # Mark the session as bound for the duration of this context and | |
| # reuse ClientSession.__enter__ to set the ContextVar. | |
| self._session._bound = True | |
| return self._session.__enter__() | |
| def __exit__( | |
| self, | |
| exc_type: Optional[Type[BaseException]], | |
| exc_val: Optional[BaseException], | |
| exc_tb: Optional["TracebackType"], | |
| ) -> None: | |
| try: | |
| # Delegate to ClientSession.__exit__ to reset the ContextVar | |
| # without ending the session when bound. | |
| self._session.__exit__(exc_type, exc_val, exc_tb) | |
| finally: | |
| # Restore the previous bound state. | |
| self._session._bound = self._prev_bound | |
| def bind(self) -> ContextManager["ClientSession"]: | |
| """Return a context manager that binds this session to the current context. | |
| Using ``with session.bind():`` will temporarily bind the session to the | |
| bound-session ContextVar without permanently changing the session's | |
| behavior when used as a context manager itself. | |
| """ | |
| return self._BindContext(self) |
| return bound_session.session | ||
| else: | ||
| raise InvalidOperation( | ||
| "Only the client that created the bound session can perform operations within its context block. See <PLACEHOLDER> for more information." |
There was a problem hiding this comment.
Avoid shipping placeholder text in user-facing exceptions. Replace <PLACEHOLDER> with the actual documentation URL (or remove the “See …” sentence) so the error message is actionable and stable.
| "Only the client that created the bound session can perform operations within its context block. See <PLACEHOLDER> for more information." | |
| "Only the client that created the bound session can perform operations within its context block." |
| """If provided session is None, lend a temporary session.""" | ||
| if session is not None: | ||
| if not isinstance(session, client_session.ClientSession): | ||
| raise ValueError( | ||
| f"'session' argument must be a ClientSession or None, not {type(session)}" | ||
| ) | ||
| if session is not None and not isinstance(session, client_session.ClientSession): | ||
| raise ValueError( | ||
| f"'session' argument must be a ClientSession or None, not {type(session)}" | ||
| ) | ||
|
|
||
| # Check for a bound session. If one exists, treat it as an explicitly passed session. | ||
| session = session or self._get_bound_session() | ||
| if session: |
There was a problem hiding this comment.
The _tmp_session docstring now understates behavior: this context manager also uses a bound session when one exists. Update the docstring to reflect that it will yield an explicitly provided session or the currently bound session, otherwise it will create an implicit temporary session.
| TypeVar, | ||
| ) | ||
|
|
||
| from _contextvars import Token |
There was a problem hiding this comment.
Avoid importing Token from the private stdlib module _contextvars; this can break on alternative interpreters (e.g., PyPy) and isn’t part of the public API. Import Token from contextvars instead (and adjust the token’s generic type to match the ContextVar’s type so the type: ignore annotations are unnecessary).
| async def __aenter__(self) -> AsyncClientSession: | ||
| if self._bound: | ||
| bound_session = _AsyncBoundClientSession(self, id(self._client)) | ||
| self._session_token = _SESSION.set(bound_session) # type: ignore[assignment] | ||
| return self | ||
|
|
||
| async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: | ||
| await self._end_session(lock=True) | ||
| if self._session_token: | ||
| _SESSION.reset(self._session_token) # type: ignore[arg-type] | ||
| self._session_token = None | ||
| self._bound = False | ||
| else: | ||
| await self._end_session(lock=True) |
There was a problem hiding this comment.
The binding implementation is not re-entrant for the same session: each __aenter__ overwrites self._session_token, so nested async with s.bind(): ... async with s.bind(): ... will clear the token in the inner __aexit__, and the outer __aexit__ will fall through to _end_session(lock=True) unexpectedly. If bind() remains supported, store the token per-context (e.g., on a separate context manager instance or a stack) so nesting works reliably and never triggers _end_session from a bind scope.
| return bound_session.session | ||
| else: | ||
| raise InvalidOperation( | ||
| "Only the client that created the bound session can perform operations within its context block. See <PLACEHOLDER> for more information." |
There was a problem hiding this comment.
Avoid shipping placeholder text in user-facing exceptions. Replace <PLACEHOLDER> with the actual documentation URL (or remove the “See …” sentence) so the error message is actionable and stable.
| "Only the client that created the bound session can perform operations within its context block. See <PLACEHOLDER> for more information." | |
| "Only the client that created the bound session can perform operations within its context block." |
[PYTHON-4542]
Changes in this PR
Add
ClientSession.bind()(and its async counterpartAsyncClientSession.bind()) for a better user experience when using explicit sessions.Test Plan
Modified
TestSession._test_ops()to include explicit bound sessions. Added a new test to verify correct nested behavior of multipleClientSession.bind()calls.Checklist
Checklist for Author
Checklist for Reviewer