Skip to content

Python 4542 - Improved sessions API#2712

Open
NoahStapp wants to merge 6 commits intomongodb:masterfrom
NoahStapp:PYTHON-4542
Open

Python 4542 - Improved sessions API#2712
NoahStapp wants to merge 6 commits intomongodb:masterfrom
NoahStapp:PYTHON-4542

Conversation

@NoahStapp
Copy link
Contributor

@NoahStapp NoahStapp commented Feb 23, 2026

[PYTHON-4542]

Changes in this PR

Add ClientSession.bind() (and its async counterpart AsyncClientSession.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 multiple ClientSession.bind() calls.

Checklist

Checklist for Author

  • Did you update the changelog (if necessary)?
  • Is there test coverage?
  • Is any followup work tracked in a JIRA ticket? If so, add link(s).

Checklist for Reviewer

  • Does the title of the PR reference a JIRA Ticket?
  • Do you fully understand the implementation? (Would you be comfortable explaining how this code works to someone else?)
  • Is all relevant documentation (README or docstring) updated?

if session is not None:
session._process_response(reply)

def _get_bound_session(self) -> Optional[AsyncClientSession]:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@NoahStapp NoahStapp marked this pull request as ready for review February 23, 2026 20:33
@NoahStapp NoahStapp requested a review from a team as a code owner February 23, 2026 20:33
@NoahStapp NoahStapp requested review from ShaneHarvey, aclark4life and caseyclements and removed request for caseyclements February 23, 2026 20:33
@codecov-commenter
Copy link

codecov-commenter commented Feb 23, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.60%. Comparing base (84814b2) to head (d6b883b).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2712      +/-   ##
==========================================
+ Coverage   87.54%   87.60%   +0.05%     
==========================================
  Files         141      141              
  Lines       24098    24154      +56     
  Branches     4118     4126       +8     
==========================================
+ Hits        21097    21159      +62     
+ Misses       2110     2107       -3     
+ Partials      891      888       -3     
Flag Coverage Δ
auth-aws-rhel8-test-auth-aws-rapid-web-identity-python3.14-cov 35.08% <34.84%> (+<0.01%) ⬆️
auth-aws-win64-test-auth-aws-rapid-web-identity-python3.14-cov 35.08% <34.84%> (+<0.01%) ⬆️
auth-enterprise-macos-test-standard-auth-latest-python3.11-auth-ssl-sharded-cluster-cov 43.74% <45.45%> (+<0.01%) ⬆️
auth-enterprise-rhel8-test-standard-auth-latest-python3.11-auth-ssl-sharded-cluster-cov 43.73% <45.45%> (+0.01%) ⬆️
auth-enterprise-win64-test-standard-auth-latest-python3.11-auth-ssl-sharded-cluster-cov 43.74% <45.45%> (+<0.01%) ⬆️
auth-oidc-local-ubuntu-22-test-auth-oidc-default 48.71% <48.48%> (+0.05%) ⬆️
compression-snappy-rhel8-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.19% <62.12%> (+0.02%) ⬆️
compression-snappy-rhel8-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.12% <62.12%> (+0.02%) ⬆️
compression-snappy-rhel8-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 56.53% <62.12%> (+0.02%) ⬆️
compression-snappy-rhel8-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.86% <62.12%> (+0.04%) ⬆️
compression-zlib-rhel8-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.20% <62.12%> (+0.02%) ⬆️
compression-zlib-rhel8-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.12% <62.12%> (+0.02%) ⬆️
compression-zlib-rhel8-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 56.53% <62.12%> (+0.02%) ⬆️
compression-zlib-rhel8-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.85% <62.12%> (+0.03%) ⬆️
compression-zstd-rhel8-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.19% <62.12%> (+0.02%) ⬆️
compression-zstd-rhel8-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.12% <62.12%> (+0.02%) ⬆️
compression-zstd-rhel8-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 56.53% <62.12%> (+0.02%) ⬆️
compression-zstd-rhel8-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.84% <62.12%> (+0.02%) ⬆️
compression-zstd-ubuntu-22-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.82% <62.12%> (+0.02%) ⬆️
coverage-report-coverage-report 87.41% <100.00%> (+0.24%) ⬆️
disable-test-commands-rhel8-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.20% <62.12%> (+0.03%) ⬆️
disable-test-commands-rhel8-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.12% <62.12%> (+0.02%) ⬆️
disable-test-commands-rhel8-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 56.53% <62.12%> (+0.02%) ⬆️
disable-test-commands-rhel8-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.84% <62.12%> (+<0.01%) ⬆️
encryption-crypt_shared-macos-test-non-standard-latest-python3.13-noauth-nossl-standalone-cov 52.82% <48.48%> (-0.01%) ⬇️
encryption-crypt_shared-macos-test-non-standard-latest-python3.14-auth-ssl-sharded-cluster-cov 54.74% <48.48%> (-0.01%) ⬇️
encryption-crypt_shared-macos-test-non-standard-latest-python3.14t-noauth-ssl-replica-set-cov 54.53% <48.48%> (-0.02%) ⬇️
encryption-crypt_shared-rhel8-test-non-standard-latest-python3.13-noauth-nossl-standalone-cov 52.81% <48.48%> (-0.02%) ⬇️
encryption-crypt_shared-rhel8-test-non-standard-latest-python3.14-auth-ssl-sharded-cluster-cov 54.61% <48.48%> (-0.02%) ⬇️
encryption-crypt_shared-rhel8-test-non-standard-latest-python3.14t-noauth-ssl-replica-set-cov 54.50% <48.48%> (-0.03%) ⬇️
encryption-crypt_shared-win64-test-non-standard-latest-python3.13-noauth-nossl-standalone-cov 52.69% <48.48%> (-0.02%) ⬇️
encryption-crypt_shared-win64-test-non-standard-latest-python3.14-auth-ssl-sharded-cluster-cov 54.72% <48.48%> (+0.06%) ⬆️
encryption-crypt_shared-win64-test-non-standard-latest-python3.14t-noauth-ssl-replica-set-cov 54.60% <48.48%> (-0.02%) ⬇️
encryption-macos-test-non-standard-latest-python3.13-noauth-nossl-standalone-cov 52.81% <48.48%> (-0.02%) ⬇️
encryption-macos-test-non-standard-latest-python3.14-auth-ssl-sharded-cluster-cov 54.71% <48.48%> (-0.02%) ⬇️
encryption-macos-test-non-standard-latest-python3.14t-noauth-ssl-replica-set-cov 54.53% <48.48%> (-0.04%) ⬇️
encryption-pyopenssl-rhel8-test-non-standard-latest-python3.13-noauth-nossl-standalone-cov 53.49% <48.48%> (-0.03%) ⬇️
encryption-pyopenssl-rhel8-test-non-standard-latest-python3.14-auth-ssl-sharded-cluster-cov 55.31% <48.48%> (+<0.01%) ⬆️
encryption-pyopenssl-rhel8-test-non-standard-latest-python3.14t-noauth-ssl-replica-set-cov 55.22% <48.48%> (+<0.01%) ⬆️
encryption-rhel8-test-non-standard-latest-python3.13-noauth-nossl-standalone-cov 52.81% <48.48%> (-0.02%) ⬇️
encryption-rhel8-test-non-standard-latest-python3.14-auth-ssl-sharded-cluster-cov 54.59% <48.48%> (-0.03%) ⬇️
encryption-rhel8-test-non-standard-latest-python3.14t-noauth-ssl-replica-set-cov 54.53% <48.48%> (-0.02%) ⬇️
encryption-win64-test-non-standard-latest-python3.13-noauth-nossl-standalone-cov 52.70% <48.48%> (-0.02%) ⬇️
encryption-win64-test-non-standard-latest-python3.14-auth-ssl-sharded-cluster-cov 54.65% <48.48%> (-0.10%) ⬇️
encryption-win64-test-non-standard-latest-python3.14t-noauth-ssl-replica-set-cov 54.61% <48.48%> (-0.02%) ⬇️
load-balancer-test-non-standard-latest-python3.14-auth-ssl-sharded-cluster-cov 48.31% <48.48%> (-0.01%) ⬇️
mongodb-latest-test-server-version-python3.10-async-auth-ssl-sharded-cluster-min-deps-cov 56.93% <62.12%> (+0.02%) ⬆️
mongodb-latest-test-server-version-python3.10-async-noauth-nossl-standalone-min-deps-cov 55.22% <62.12%> (+0.01%) ⬆️
mongodb-latest-test-server-version-python3.10-sync-auth-ssl-sharded-cluster-min-deps-cov 59.11% <62.12%> (+0.01%) ⬆️
mongodb-latest-test-server-version-python3.10-sync-noauth-nossl-replica-set-min-deps-cov 59.26% <62.12%> (+0.01%) ⬆️
mongodb-latest-test-server-version-python3.11-async-noauth-nossl-replica-set-cov 57.06% <62.12%> (+0.03%) ⬆️
mongodb-rapid-test-server-version-python3.10-async-auth-ssl-sharded-cluster-min-deps-cov 56.92% <62.12%> (+0.01%) ⬆️
mongodb-rapid-test-server-version-python3.10-async-noauth-nossl-standalone-min-deps-cov 55.22% <62.12%> (+0.02%) ⬆️
mongodb-rapid-test-server-version-python3.10-sync-auth-ssl-sharded-cluster-min-deps-cov 59.11% <62.12%> (+0.01%) ⬆️
mongodb-rapid-test-server-version-python3.10-sync-noauth-nossl-replica-set-min-deps-cov 59.26% <62.12%> (+0.01%) ⬆️
mongodb-rapid-test-server-version-python3.11-async-noauth-nossl-replica-set-cov 57.05% <62.12%> (+<0.01%) ⬆️
mongodb-v4.2-test-server-version-python3.10-async-auth-ssl-sharded-cluster-min-deps-cov 54.63% <62.12%> (+0.01%) ⬆️
mongodb-v4.2-test-server-version-python3.10-async-noauth-nossl-standalone-min-deps-cov 53.19% <62.12%> (+0.05%) ⬆️
mongodb-v4.2-test-server-version-python3.10-sync-auth-ssl-sharded-cluster-min-deps-cov 56.81% <62.12%> (+0.02%) ⬆️
mongodb-v4.2-test-server-version-python3.10-sync-noauth-nossl-replica-set-min-deps-cov 56.93% <62.12%> (+0.02%) ⬆️
mongodb-v4.2-test-server-version-python3.11-async-noauth-nossl-replica-set-cov 54.74% <62.12%> (-0.03%) ⬇️
mongodb-v4.4-test-server-version-python3.10-async-auth-ssl-sharded-cluster-min-deps-cov 55.02% <62.12%> (+0.02%) ⬆️
mongodb-v4.4-test-server-version-python3.10-async-noauth-nossl-standalone-min-deps-cov 53.45% <62.12%> (+0.02%) ⬆️
mongodb-v4.4-test-server-version-python3.10-sync-auth-ssl-sharded-cluster-min-deps-cov 57.22% <62.12%> (+0.02%) ⬆️
mongodb-v4.4-test-server-version-python3.10-sync-noauth-nossl-replica-set-min-deps-cov 57.30% <62.12%> (+0.02%) ⬆️
mongodb-v4.4-test-server-version-python3.11-async-noauth-nossl-replica-set-cov 55.08% <62.12%> (+<0.01%) ⬆️
mongodb-v5.0-test-server-version-python3.10-async-auth-ssl-sharded-cluster-min-deps-cov 55.21% <62.12%> (+0.01%) ⬆️
mongodb-v5.0-test-server-version-python3.10-async-noauth-nossl-standalone-min-deps-cov 53.62% <62.12%> (+0.04%) ⬆️
mongodb-v5.0-test-server-version-python3.10-sync-auth-ssl-sharded-cluster-min-deps-cov 57.41% <62.12%> (+0.02%) ⬆️
mongodb-v5.0-test-server-version-python3.10-sync-noauth-nossl-replica-set-min-deps-cov 57.54% <62.12%> (+0.02%) ⬆️
mongodb-v5.0-test-server-version-python3.11-async-noauth-nossl-replica-set-cov 55.33% <62.12%> (+0.02%) ⬆️
mongodb-v6.0-test-server-version-python3.10-async-auth-ssl-sharded-cluster-min-deps-cov 55.22% <62.12%> (+0.02%) ⬆️
mongodb-v6.0-test-server-version-python3.10-async-noauth-nossl-standalone-min-deps-cov 53.63% <62.12%> (+0.03%) ⬆️
mongodb-v6.0-test-server-version-python3.10-sync-auth-ssl-sharded-cluster-min-deps-cov 57.43% <62.12%> (+0.02%) ⬆️
mongodb-v6.0-test-server-version-python3.10-sync-noauth-nossl-replica-set-min-deps-cov 57.58% <62.12%> (+0.01%) ⬆️
mongodb-v6.0-test-server-version-python3.11-async-noauth-nossl-replica-set-cov 55.36% <62.12%> (+0.01%) ⬆️
mongodb-v7.0-test-server-version-python3.10-async-auth-ssl-sharded-cluster-min-deps-cov 55.25% <62.12%> (+0.03%) ⬆️
mongodb-v7.0-test-server-version-python3.10-async-noauth-nossl-standalone-min-deps-cov 53.62% <62.12%> (+0.01%) ⬆️
mongodb-v7.0-test-server-version-python3.10-sync-auth-ssl-sharded-cluster-min-deps-cov 57.44% <62.12%> (+0.02%) ⬆️
mongodb-v7.0-test-server-version-python3.10-sync-noauth-nossl-replica-set-min-deps-cov 57.58% <62.12%> (+0.01%) ⬆️
mongodb-v7.0-test-server-version-python3.11-async-noauth-nossl-replica-set-cov 55.37% <62.12%> (+0.02%) ⬆️
mongodb-v8.0-test-server-version-python3.10-async-auth-ssl-sharded-cluster-min-deps-cov 56.93% <62.12%> (+0.01%) ⬆️
mongodb-v8.0-test-server-version-python3.10-async-noauth-nossl-standalone-min-deps-cov 55.23% <62.12%> (+0.03%) ⬆️
mongodb-v8.0-test-server-version-python3.10-sync-auth-ssl-sharded-cluster-min-deps-cov 59.11% <62.12%> (+0.01%) ⬆️
mongodb-v8.0-test-server-version-python3.10-sync-noauth-nossl-replica-set-min-deps-cov 59.26% <62.12%> (+0.01%) ⬆️
mongodb-v8.0-test-server-version-python3.11-async-noauth-nossl-replica-set-cov 57.06% <62.12%> (+0.02%) ⬆️
no-c-ext-rhel8-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 56.40% <62.12%> (+<0.01%) ⬆️
no-c-ext-rhel8-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 58.34% <62.12%> (+0.01%) ⬆️
no-c-ext-rhel8-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 57.75% <62.12%> (+0.01%) ⬆️
no-c-ext-rhel8-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 56.05% <62.12%> (+0.01%) ⬆️
ocsp-rhel8-test-ocsp-ecdsa-valid-cert-server-staples-latest-python3.14-cov 34.05% <33.33%> (-0.02%) ⬇️
ocsp-rhel8-test-ocsp-rsa-valid-cert-server-staples-latest-python3.14-cov 34.08% <33.33%> (+0.02%) ⬆️
pyopenssl-macos-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.03% <62.12%> (+0.02%) ⬆️
pyopenssl-rhel8-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.03% <62.12%> (+0.02%) ⬆️
pyopenssl-win64-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 56.97% <62.12%> (+0.02%) ⬆️
stable-api-accept-v2-rhel8-auth-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.20% <62.12%> (+0.02%) ⬆️
stable-api-accept-v2-rhel8-auth-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.84% <62.12%> (+0.02%) ⬆️
stable-api-require-v1-rhel8-auth-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.19% <62.12%> (+0.04%) ⬆️
stable-api-require-v1-rhel8-auth-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 56.40% <62.12%> (+0.02%) ⬆️
stable-api-require-v1-rhel8-auth-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.83% <62.12%> (+0.02%) ⬆️
storage-inmemory-rhel8-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.20% <62.12%> (+0.03%) ⬆️
storage-inmemory-rhel8-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.84% <62.12%> (+0.02%) ⬆️
test-macos-arm64-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.17% <62.12%> (+0.02%) ⬆️
test-macos-arm64-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.12% <62.12%> (+0.02%) ⬆️
test-macos-arm64-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 56.54% <62.12%> (+0.02%) ⬆️
test-macos-arm64-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.81% <62.12%> (+0.03%) ⬆️
test-macos-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.19% <62.12%> (+0.03%) ⬆️
test-macos-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.12% <62.12%> (+0.02%) ⬆️
test-macos-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 56.53% <62.12%> (+0.03%) ⬆️
test-macos-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.81% <62.12%> (+0.03%) ⬆️
test-numpy-macos-arm64-test-numpy-python3.14-python3.14-cov 32.25% <24.24%> (-0.02%) ⬇️
test-numpy-macos-test-numpy-python3.14-python3.14-cov 32.25% <24.24%> (-0.02%) ⬇️
test-numpy-rhel8-test-numpy-python3.14-python3.14-cov 32.25% <24.24%> (-0.03%) ⬇️
test-numpy-win32-test-numpy-python3.14-python3.14-cov 32.23% <24.24%> (-0.01%) ⬇️
test-numpy-win64-test-numpy-python3.14-python3.14-cov 32.23% <24.24%> (-0.01%) ⬇️
test-win32-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.04% <62.12%> (+0.02%) ⬆️
test-win32-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.06% <62.12%> (+0.02%) ⬆️
test-win32-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 56.46% <62.12%> (+0.01%) ⬆️
test-win32-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.68% <62.12%> (+0.02%) ⬆️
test-win64-test-standard-latest-python3.11-async-noauth-nossl-standalone-cov 55.03% <62.12%> (+0.01%) ⬆️
test-win64-test-standard-latest-python3.12-async-noauth-ssl-replica-set-cov 57.06% <62.12%> (+0.02%) ⬆️
test-win64-test-standard-latest-python3.13-async-auth-ssl-sharded-cluster-cov 56.47% <62.12%> (+0.02%) ⬆️
test-win64-test-standard-latest-python3.14-async-noauth-nossl-standalone-cov 54.68% <62.12%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.


- 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<PLACEHOLDER> should be the MongoDB docs ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what's going on here before the changes. How does:

if session:
    return session

lend a temporary session?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third <PLACEHOLDER> maybe synchro'd

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a ContextVar, and updates MongoClient/AsyncMongoClient to 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.

Comment on lines +564 to +567
def bind(self) -> AsyncClientSession:
self._bound = True
return self

Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 2268 to +2278
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:
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +11
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.
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

Copilot uses AI. Check for mistakes.
Comment on lines +563 to +566
def bind(self) -> ClientSession:
self._bound = True
return self

Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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)

Copilot uses AI. Check for mistakes.
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."
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"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."

Copilot uses AI. Check for mistakes.
Comment on lines 2266 to +2274
"""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:
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
TypeVar,
)

from _contextvars import Token
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 568 to +580
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)
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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."
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"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."

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants