]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Added an option to permanently close sessions.
authorDaniel Krzeminski <dankrzeminski32@gmail.com>
Tue, 22 Aug 2023 18:18:48 +0000 (14:18 -0400)
committerFederico Caselli <cfederico87@gmail.com>
Mon, 2 Oct 2023 20:52:26 +0000 (22:52 +0200)
Set to ``False`` the new parameter :paramref:`_orm.Session.close_is_reset`
will prevent a :class:`_orm.Session` from performing any other
operation after :meth:`_orm.Session.close` has been called.

Added new method :meth:`_orm.Session.reset` that will reset a :class:`_orm.Session`
to its initial state. This is an alias of :meth:`_orm.Session.close`,
unless :paramref:`_orm.Session.close_is_reset` is set to ``False``.

Fixes: #7787
Closes: #10137
Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/10137
Pull-request-sha: 881241e19b24f34e4553b2d58070bfa99597f4e4

Change-Id: Ic3512874600daff4ed66bb0cd29a3a88f667d258

doc/build/changelog/unreleased_20/7787.rst [new file with mode: 0644]
doc/build/orm/session_basics.rst
lib/sqlalchemy/ext/asyncio/scoping.py
lib/sqlalchemy/ext/asyncio/session.py
lib/sqlalchemy/orm/scoping.py
lib/sqlalchemy/orm/session.py
test/ext/asyncio/test_session_py3k.py
test/orm/test_session.py

diff --git a/doc/build/changelog/unreleased_20/7787.rst b/doc/build/changelog/unreleased_20/7787.rst
new file mode 100644 (file)
index 0000000..4ba0c9c
--- /dev/null
@@ -0,0 +1,12 @@
+.. change::
+    :tags: usecase, orm
+    :tickets: 7787
+
+    Added an option to permanently close sessions.
+    Set to ``False`` the new parameter :paramref:`_orm.Session.close_resets_only`
+    will prevent a :class:`_orm.Session` from performing any other
+    operation after :meth:`_orm.Session.close` has been called.
+
+    Added new method :meth:`_orm.Session.reset` that will reset a :class:`_orm.Session`
+    to its initial state. This is an alias of :meth:`_orm.Session.close`,
+    unless :paramref:`_orm.Session.close_resets_only` is set to ``False``.
\ No newline at end of file
index 6cdb58e1a21fbbfd446c27c35f6a8c710c147da2..0fcbf7900b13b4631d6d80237694403b5f97db10 100644 (file)
@@ -723,10 +723,22 @@ transactional/connection resources from the :class:`_engine.Engine` object(s)
 to which it is bound.   When connections are returned to the connection pool,
 transactional state is rolled back as well.
 
-When the :class:`_orm.Session` is closed, it is essentially in the
+By default, when the :class:`_orm.Session` is closed, it is essentially in the
 original state as when it was first constructed, and **may be used again**.
 In this sense, the :meth:`_orm.Session.close` method is more like a "reset"
 back to the clean state and not as much like a "database close" method.
+In this mode of operation the method :meth:`_orm.Session.reset` is an alias to
+:meth:`_orm.Session.close` and behaves in the same way.
+
+The default behavior of :meth:`_orm.Session.close` can be changed by setting the
+parameter :paramref:`_orm.Session.close_resets_only` to ``False``, indicating that
+the :class:`_orm.Session` cannot be reused after the method
+:meth:`_orm.Session.close` has been called. In this mode of operation the
+:meth:`_orm.Session.reset` method will allow multiple "reset" of the session,
+behaving like :meth:`_orm.Session.close` when
+:paramref:`_orm.Session.close_resets_only` is set to ``True``.
+
+.. versionadded:: 2.0.22
 
 It's recommended that the scope of a :class:`_orm.Session` be limited by
 a call to :meth:`_orm.Session.close` at the end, especially if the
index b70c3366b16513045d421188ca56a98aa1287095..7c0ce06f1c70d503604dcedda4055e98aa97bc72 100644 (file)
@@ -77,6 +77,7 @@ _T = TypeVar("_T", bound=Any)
         "begin",
         "begin_nested",
         "close",
+        "reset",
         "commit",
         "connection",
         "delete",
@@ -447,33 +448,44 @@ class async_scoped_session(Generic[_AS]):
             Proxied for the :class:`_asyncio.AsyncSession` class on
             behalf of the :class:`_asyncio.scoping.async_scoped_session` class.
 
-        This expunges all ORM objects associated with this
-        :class:`_asyncio.AsyncSession`, ends any transaction in progress and
-        :term:`releases` any :class:`_asyncio.AsyncConnection` objects which
-        this :class:`_asyncio.AsyncSession` itself has checked out from
-        associated :class:`_asyncio.AsyncEngine` objects. The operation then
-        leaves the :class:`_asyncio.AsyncSession` in a state which it may be
-        used again.
+        .. seealso::
+
+            :meth:`_orm.Session.close` - main documentation for
+            "close"
+
+            :ref:`session_closing` - detail on the semantics of
+            :meth:`_asyncio.AsyncSession.close` and
+            :meth:`_asyncio.AsyncSession.reset`.
+
+
+        """  # noqa: E501
+
+        return await self._proxied.close()
+
+    async def reset(self) -> None:
+        r"""Close out the transactional resources and ORM objects used by this
+        :class:`_orm.Session`, resetting the session to its initial state.
 
-        .. tip::
+        .. container:: class_bases
 
-            The :meth:`_asyncio.AsyncSession.close` method **does not prevent
-            the Session from being used again**. The
-            :class:`_asyncio.AsyncSession` itself does not actually have a
-            distinct "closed" state; it merely means the
-            :class:`_asyncio.AsyncSession` will release all database
-            connections and ORM objects.
+            Proxied for the :class:`_asyncio.AsyncSession` class on
+            behalf of the :class:`_asyncio.scoping.async_scoped_session` class.
 
+        .. versionadded:: 2.0.22
 
         .. seealso::
 
+            :meth:`_orm.Session.reset` - main documentation for
+            "reset"
+
             :ref:`session_closing` - detail on the semantics of
-            :meth:`_asyncio.AsyncSession.close`
+            :meth:`_asyncio.AsyncSession.close` and
+            :meth:`_asyncio.AsyncSession.reset`.
 
 
         """  # noqa: E501
 
-        return await self._proxied.close()
+        return await self._proxied.reset()
 
     async def commit(self) -> None:
         r"""Commit the current transaction in progress.
@@ -483,6 +495,11 @@ class async_scoped_session(Generic[_AS]):
             Proxied for the :class:`_asyncio.AsyncSession` class on
             behalf of the :class:`_asyncio.scoping.async_scoped_session` class.
 
+        .. seealso::
+
+            :meth:`_orm.Session.commit` - main documentation for
+            "commit"
+
         """  # noqa: E501
 
         return await self._proxied.commit()
@@ -1014,6 +1031,11 @@ class async_scoped_session(Generic[_AS]):
             Proxied for the :class:`_asyncio.AsyncSession` class on
             behalf of the :class:`_asyncio.scoping.async_scoped_session` class.
 
+        .. seealso::
+
+            :meth:`_orm.Session.rollback` - main documentation for
+            "rollback"
+
         """  # noqa: E501
 
         return await self._proxied.rollback()
index da69c4fb3efc651d6a6354f211a6d46866008130..d5b439fcb79da1351d56bcc17858752eb3beb791 100644 (file)
@@ -951,42 +951,58 @@ class AsyncSession(ReversibleProxy[Session]):
         return AsyncSessionTransaction(self, nested=True)
 
     async def rollback(self) -> None:
-        """Rollback the current transaction in progress."""
+        """Rollback the current transaction in progress.
+
+        .. seealso::
+
+            :meth:`_orm.Session.rollback` - main documentation for
+            "rollback"
+        """
         await greenlet_spawn(self.sync_session.rollback)
 
     async def commit(self) -> None:
-        """Commit the current transaction in progress."""
+        """Commit the current transaction in progress.
+
+        .. seealso::
+
+            :meth:`_orm.Session.commit` - main documentation for
+            "commit"
+        """
         await greenlet_spawn(self.sync_session.commit)
 
     async def close(self) -> None:
         """Close out the transactional resources and ORM objects used by this
         :class:`_asyncio.AsyncSession`.
 
-        This expunges all ORM objects associated with this
-        :class:`_asyncio.AsyncSession`, ends any transaction in progress and
-        :term:`releases` any :class:`_asyncio.AsyncConnection` objects which
-        this :class:`_asyncio.AsyncSession` itself has checked out from
-        associated :class:`_asyncio.AsyncEngine` objects. The operation then
-        leaves the :class:`_asyncio.AsyncSession` in a state which it may be
-        used again.
+        .. seealso::
+
+            :meth:`_orm.Session.close` - main documentation for
+            "close"
 
-        .. tip::
+            :ref:`session_closing` - detail on the semantics of
+            :meth:`_asyncio.AsyncSession.close` and
+            :meth:`_asyncio.AsyncSession.reset`.
 
-            The :meth:`_asyncio.AsyncSession.close` method **does not prevent
-            the Session from being used again**. The
-            :class:`_asyncio.AsyncSession` itself does not actually have a
-            distinct "closed" state; it merely means the
-            :class:`_asyncio.AsyncSession` will release all database
-            connections and ORM objects.
+        """
+        await greenlet_spawn(self.sync_session.close)
 
+    async def reset(self) -> None:
+        """Close out the transactional resources and ORM objects used by this
+        :class:`_orm.Session`, resetting the session to its initial state.
+
+        .. versionadded:: 2.0.22
 
         .. seealso::
 
+            :meth:`_orm.Session.reset` - main documentation for
+            "reset"
+
             :ref:`session_closing` - detail on the semantics of
-            :meth:`_asyncio.AsyncSession.close`
+            :meth:`_asyncio.AsyncSession.close` and
+            :meth:`_asyncio.AsyncSession.reset`.
 
         """
-        await greenlet_spawn(self.sync_session.close)
+        await greenlet_spawn(self.sync_session.reset)
 
     async def aclose(self) -> None:
         """A synonym for :meth:`_asyncio.AsyncSession.close`.
index fc144d98c4e0172537f20424094aa7b7c14a3456..c1ef8871f18cf46b915ee2c5e53434a533074011 100644 (file)
@@ -108,6 +108,7 @@ __all__ = ["scoped_session"]
         "begin",
         "begin_nested",
         "close",
+        "reset",
         "commit",
         "connection",
         "delete",
@@ -491,12 +492,17 @@ class scoped_session(Generic[_S]):
 
         .. tip::
 
-            The :meth:`_orm.Session.close` method **does not prevent the
-            Session from being used again**.   The :class:`_orm.Session` itself
-            does not actually have a distinct "closed" state; it merely means
+            In the default running mode the :meth:`_orm.Session.close`
+            method **does not prevent the Session from being used again**.
+            The :class:`_orm.Session` itself does not actually have a
+            distinct "closed" state; it merely means
             the :class:`_orm.Session` will release all database connections
             and ORM objects.
 
+            Setting the parameter :paramref:`_orm.Session.close_resets_only`
+            to ``False`` will instead make the ``close`` final, meaning that
+            any further action on the session will be forbidden.
+
         .. versionchanged:: 1.4  The :meth:`.Session.close` method does not
            immediately create a new :class:`.SessionTransaction` object;
            instead, the new :class:`.SessionTransaction` is created only if
@@ -505,13 +511,49 @@ class scoped_session(Generic[_S]):
         .. seealso::
 
             :ref:`session_closing` - detail on the semantics of
-            :meth:`_orm.Session.close`
+            :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
+
+            :meth:`_orm.Session.reset` - a similar method that behaves like
+            ``close()`` with  the parameter
+            :paramref:`_orm.Session.close_resets_only` set to ``True``.
 
 
         """  # noqa: E501
 
         return self._proxied.close()
 
+    def reset(self) -> None:
+        r"""Close out the transactional resources and ORM objects used by this
+        :class:`_orm.Session`, resetting the session to its initial state.
+
+        .. container:: class_bases
+
+            Proxied for the :class:`_orm.Session` class on
+            behalf of the :class:`_orm.scoping.scoped_session` class.
+
+        This method provides for same "reset-only" behavior that the
+        :meth:_orm.Session.close method has provided historically, where the
+        state of the :class:`_orm.Session` is reset as though the object were
+        brand new, and ready to be used again.
+        The method may then be useful for :class:`_orm.Session` objects
+        which set :paramref:`_orm.Session.close_resets_only` to ``False``,
+        so that "reset only" behavior is still available from this method.
+
+        .. versionadded:: 2.0.22
+
+        .. seealso::
+
+            :ref:`session_closing` - detail on the semantics of
+            :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
+
+            :meth:`_orm.Session.close` - a similar method will additionally
+            prevent re-use of the Session when the parameter
+            :paramref:`_orm.Session.close_resets_only` is set to ``False``.
+
+        """  # noqa: E501
+
+        return self._proxied.reset()
+
     def commit(self) -> None:
         r"""Flush pending changes and commit the current transaction.
 
index e5eb5036dd738b502fa89c7cd3548724c5620196..96cd608ac423b45ab9bd8aeef7db21b8c474b1e0 100644 (file)
@@ -83,6 +83,7 @@ from ..sql import roles
 from ..sql import Select
 from ..sql import TableClause
 from ..sql import visitors
+from ..sql.base import _NoArg
 from ..sql.base import CompileState
 from ..sql.schema import Table
 from ..sql.selectable import ForUpdateArg
@@ -881,6 +882,12 @@ class SessionTransaction(_StateChange, TransactionalContext):
         self.nested = nested = origin is SessionTransactionOrigin.BEGIN_NESTED
         self.origin = origin
 
+        if session._close_state is _SessionCloseState.CLOSED:
+            raise sa_exc.InvalidRequestError(
+                "This Session has been permanently closed and is unable "
+                "to handle any more transaction requests."
+            )
+
         if nested:
             if not parent:
                 raise sa_exc.InvalidRequestError(
@@ -1372,6 +1379,12 @@ class SessionTransaction(_StateChange, TransactionalContext):
         return self._state not in (COMMITTED, CLOSED)
 
 
+class _SessionCloseState(Enum):
+    ACTIVE = 1
+    CLOSED = 2
+    CLOSE_IS_RESET = 3
+
+
 class Session(_SessionClassMethods, EventTarget):
     """Manages persistence operations for ORM-mapped objects.
 
@@ -1416,6 +1429,7 @@ class Session(_SessionClassMethods, EventTarget):
     twophase: bool
     join_transaction_mode: JoinTransactionMode
     _query_cls: Type[Query[Any]]
+    _close_state: _SessionCloseState
 
     def __init__(
         self,
@@ -1432,6 +1446,7 @@ class Session(_SessionClassMethods, EventTarget):
         query_cls: Optional[Type[Query[Any]]] = None,
         autocommit: Literal[False] = False,
         join_transaction_mode: JoinTransactionMode = "conditional_savepoint",
+        close_resets_only: Union[bool, _NoArg] = _NoArg.NO_ARG,
     ):
         r"""Construct a new :class:`_orm.Session`.
 
@@ -1639,6 +1654,18 @@ class Session(_SessionClassMethods, EventTarget):
 
           .. versionadded:: 2.0.0rc1
 
+        :param close_resets_only: Defaults to ``True``. Determines if
+          the session should reset itself after calling ``.close()``
+          or should pass in a no longer usable state, disabling re-use.
+
+          .. versionadded:: 2.0.22 added flag ``close_resets_only``.
+            A future SQLAlchemy version may change the default value of
+            this flag to ``False``.
+
+          .. seealso::
+
+            :ref:`session_closing` - Detail on the semantics of
+            :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
 
         """  # noqa
 
@@ -1671,6 +1698,13 @@ class Session(_SessionClassMethods, EventTarget):
         self.autoflush = autoflush
         self.expire_on_commit = expire_on_commit
         self.enable_baked_queries = enable_baked_queries
+
+        # the idea is that at some point NO_ARG will warn that in the future
+        # the default will switch to close_resets_only=False.
+        if close_resets_only or close_resets_only is _NoArg.NO_ARG:
+            self._close_state = _SessionCloseState.CLOSE_IS_RESET
+        else:
+            self._close_state = _SessionCloseState.ACTIVE
         if (
             join_transaction_mode
             and join_transaction_mode
@@ -2393,12 +2427,17 @@ class Session(_SessionClassMethods, EventTarget):
 
         .. tip::
 
-            The :meth:`_orm.Session.close` method **does not prevent the
-            Session from being used again**.   The :class:`_orm.Session` itself
-            does not actually have a distinct "closed" state; it merely means
+            In the default running mode the :meth:`_orm.Session.close`
+            method **does not prevent the Session from being used again**.
+            The :class:`_orm.Session` itself does not actually have a
+            distinct "closed" state; it merely means
             the :class:`_orm.Session` will release all database connections
             and ORM objects.
 
+            Setting the parameter :paramref:`_orm.Session.close_resets_only`
+            to ``False`` will instead make the ``close`` final, meaning that
+            any further action on the session will be forbidden.
+
         .. versionchanged:: 1.4  The :meth:`.Session.close` method does not
            immediately create a new :class:`.SessionTransaction` object;
            instead, the new :class:`.SessionTransaction` is created only if
@@ -2407,11 +2446,40 @@ class Session(_SessionClassMethods, EventTarget):
         .. seealso::
 
             :ref:`session_closing` - detail on the semantics of
-            :meth:`_orm.Session.close`
+            :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
+
+            :meth:`_orm.Session.reset` - a similar method that behaves like
+            ``close()`` with  the parameter
+            :paramref:`_orm.Session.close_resets_only` set to ``True``.
 
         """
         self._close_impl(invalidate=False)
 
+    def reset(self) -> None:
+        """Close out the transactional resources and ORM objects used by this
+        :class:`_orm.Session`, resetting the session to its initial state.
+
+        This method provides for same "reset-only" behavior that the
+        :meth:_orm.Session.close method has provided historically, where the
+        state of the :class:`_orm.Session` is reset as though the object were
+        brand new, and ready to be used again.
+        The method may then be useful for :class:`_orm.Session` objects
+        which set :paramref:`_orm.Session.close_resets_only` to ``False``,
+        so that "reset only" behavior is still available from this method.
+
+        .. versionadded:: 2.0.22
+
+        .. seealso::
+
+            :ref:`session_closing` - detail on the semantics of
+            :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
+
+            :meth:`_orm.Session.close` - a similar method will additionally
+            prevent re-use of the Session when the parameter
+            :paramref:`_orm.Session.close_resets_only` is set to ``False``.
+        """
+        self._close_impl(invalidate=False, is_reset=True)
+
     def invalidate(self) -> None:
         """Close this Session, using connection invalidation.
 
@@ -2448,7 +2516,9 @@ class Session(_SessionClassMethods, EventTarget):
         """
         self._close_impl(invalidate=True)
 
-    def _close_impl(self, invalidate: bool) -> None:
+    def _close_impl(self, invalidate: bool, is_reset: bool = False) -> None:
+        if not is_reset and self._close_state is _SessionCloseState.ACTIVE:
+            self._close_state = _SessionCloseState.CLOSED
         self.expunge_all()
         if self._transaction is not None:
             for transaction in self._transaction._iterate_self_and_parents():
index 8fa174eebaaea5f0e3f8ed220f8e86cb03bd1922..9d17d0b1dd079c9632f5138cee4c865223ea4f61 100644 (file)
@@ -891,6 +891,11 @@ class AsyncProxyTest(AsyncFixture):
 
         is_(async_object_session(u3), None)
 
+        await s2.reset()
+        is_(async_object_session(u2), None)
+        s2.add(u2)
+
+        is_(async_object_session(u2), s2)
         await s2.close()
         is_(async_object_session(u2), None)
 
index b304ac574540085e2f5837fbfc35657fe617b5c9..cbe5eca4694133bee0063d0a0aecc356d5bc5424 100644 (file)
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
 import sqlalchemy as sa
 from sqlalchemy import delete
 from sqlalchemy import event
+from sqlalchemy import exc
 from sqlalchemy import ForeignKey
 from sqlalchemy import insert
 from sqlalchemy import inspect
@@ -115,14 +116,14 @@ class ExecutionTest(_fixtures.FixtureTest):
     def test_no_string_execute(self, connection):
         with Session(bind=connection) as sess:
             with expect_raises_message(
-                sa.exc.ArgumentError,
+                exc.ArgumentError,
                 r"Textual SQL expression 'select \* from users where.*' "
                 "should be explicitly declared",
             ):
                 sess.execute("select * from users where id=:id", {"id": 7})
 
             with expect_raises_message(
-                sa.exc.ArgumentError,
+                exc.ArgumentError,
                 r"Textual SQL expression 'select id from users .*' "
                 "should be explicitly declared",
             ):
@@ -253,7 +254,7 @@ class TransScopingTest(_fixtures.FixtureTest):
 
         orm_trigger = trigger == "lazyload" or trigger == "unitofwork"
         with expect_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             r"Autobegin is disabled on this Session; please call "
             r"session.begin\(\) to start a new transaction",
         ):
@@ -304,7 +305,7 @@ class TransScopingTest(_fixtures.FixtureTest):
         s.begin()  # OK
 
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "A transaction is already begun on this Session.",
             s.begin,
         )
@@ -504,7 +505,7 @@ class SessionUtilTest(_fixtures.FixtureTest):
         sess.flush()
         assert u1 not in sess
 
-        assert_raises(sa.exc.InvalidRequestError, sess.add, u1)
+        assert_raises(exc.InvalidRequestError, sess.add, u1)
         make_transient(u1)
         sess.add(u1)
         sess.flush()
@@ -550,7 +551,7 @@ class SessionUtilTest(_fixtures.FixtureTest):
         u1 = User(id=1, name="test")
         sess.add(u1)
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "Given object must be transient",
             make_transient_to_detached,
             u1,
@@ -566,7 +567,7 @@ class SessionUtilTest(_fixtures.FixtureTest):
         sess.commit()
         sess.expunge(u1)
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "Given object must be transient",
             make_transient_to_detached,
             u1,
@@ -640,7 +641,7 @@ class SessionStateTest(_fixtures.FixtureTest):
         Session(autocommit=False)
 
         with expect_raises_message(
-            sa.exc.ArgumentError, "autocommit=True is no longer supported"
+            exc.ArgumentError, "autocommit=True is no longer supported"
         ):
             Session(autocommit=True)
 
@@ -721,7 +722,7 @@ class SessionStateTest(_fixtures.FixtureTest):
 
         # will raise for null email address
         assert_raises_message(
-            sa.exc.DBAPIError,
+            exc.DBAPIError,
             ".*raised as a result of Query-invoked autoflush; consider using "
             "a session.no_autoflush block.*",
             s.query(User).first,
@@ -741,7 +742,7 @@ class SessionStateTest(_fixtures.FixtureTest):
         sess.delete(u1)
         sess.flush()
         assert u1 not in sess
-        assert_raises(sa.exc.InvalidRequestError, sess.add, u1)
+        assert_raises(exc.InvalidRequestError, sess.add, u1)
         assert sess.in_transaction()
         sess.rollback()
         assert u1 in sess
@@ -749,7 +750,7 @@ class SessionStateTest(_fixtures.FixtureTest):
         sess.delete(u1)
         sess.commit()
         assert u1 not in sess
-        assert_raises(sa.exc.InvalidRequestError, sess.add, u1)
+        assert_raises(exc.InvalidRequestError, sess.add, u1)
 
         make_transient(u1)
         sess.add(u1)
@@ -909,7 +910,7 @@ class SessionStateTest(_fixtures.FixtureTest):
         user = User(name="u1")
 
         assert_raises_message(
-            sa.exc.InvalidRequestError, "is not persisted", s.delete, user
+            exc.InvalidRequestError, "is not persisted", s.delete, user
         )
 
         s.add(user)
@@ -937,7 +938,7 @@ class SessionStateTest(_fixtures.FixtureTest):
 
         s2 = fixture_session()
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "is already attached to session",
             s2.delete,
             user,
@@ -945,7 +946,7 @@ class SessionStateTest(_fixtures.FixtureTest):
         u2 = s2.get(User, user.id)
         s2.expunge(u2)
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "another instance .* is already present",
             s.delete,
             u2,
@@ -973,7 +974,7 @@ class SessionStateTest(_fixtures.FixtureTest):
         s1.add(u1)
 
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "Object '<User.*?>' is already attached to session",
             s2.add,
             u1,
@@ -997,7 +998,7 @@ class SessionStateTest(_fixtures.FixtureTest):
             s.identity_map.add(sa.orm.attributes.instance_state(u1))
 
             assert_raises_message(
-                sa.exc.InvalidRequestError,
+                exc.InvalidRequestError,
                 "Can't attach instance <User.*?>; another instance "
                 "with key .*? is already "
                 "present in this session.",
@@ -1057,7 +1058,7 @@ class SessionStateTest(_fixtures.FixtureTest):
         u1 = User(name="u1")
         sess1.add(u1)
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "already attached to session",
             sess2.add,
             u1,
@@ -1087,7 +1088,7 @@ class SessionStateTest(_fixtures.FixtureTest):
         assert u2 in sess
 
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "Can't attach instance <User.*?>; another instance "
             "with key .*? is already "
             "present in this session.",
@@ -1176,7 +1177,7 @@ class SessionStateTest(_fixtures.FixtureTest):
     def test_extra_dirty_state_post_flush_warning(self):
         s, a1, a2 = self._test_extra_dirty_state()
         assert_warns_message(
-            sa.exc.SAWarning,
+            exc.SAWarning,
             "Attribute history events accumulated on 1 previously "
             "clean instances",
             s.commit,
@@ -1264,6 +1265,47 @@ class SessionStateTest(_fixtures.FixtureTest):
         assert u1 not in sess
         assert object_session(u1) is None
 
+    @testing.combinations(True, False, "default", argnames="close_resets_only")
+    @testing.variation("method", ["close", "reset"])
+    def test_session_close_resets_only(self, close_resets_only, method):
+        users, User = self.tables.users, self.classes.User
+        self.mapper_registry.map_imperatively(User, users)
+
+        if close_resets_only is True:
+            kw = {"close_resets_only": True}
+        elif close_resets_only is False:
+            kw = {"close_resets_only": False}
+        else:
+            eq_(close_resets_only, "default")
+            kw = {}
+
+        s = fixture_session(**kw)
+        u1 = User()
+        s.add(u1)
+        assertions.in_(u1, s)
+
+        if method.reset:
+            s.reset()
+        elif method.close:
+            s.close()
+        else:
+            method.fail()
+
+        assertions.not_in(u1, s)
+
+        u2 = User()
+        if method.close and close_resets_only is False:
+            with expect_raises_message(
+                exc.InvalidRequestError,
+                "This Session has been permanently closed and is unable "
+                "to handle any more transaction requests.",
+            ):
+                s.add(u2)
+            assertions.not_in(u2, s)
+        else:
+            s.add(u2)
+            assertions.in_(u2, s)
+
 
 class DeferredRelationshipExpressionTest(_fixtures.FixtureTest):
     run_inserts = None
@@ -1345,7 +1387,7 @@ class DeferredRelationshipExpressionTest(_fixtures.FixtureTest):
         u = User(name="ed", addresses=[Address(email_address="foo")])
 
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "Can't resolve value for column users.id on object "
             ".User.*.; no value has been set for this column",
             (Address.user == u).left.callable,
@@ -1353,7 +1395,7 @@ class DeferredRelationshipExpressionTest(_fixtures.FixtureTest):
 
         q = sess.query(Address).filter(Address.user == u)
         assert_raises_message(
-            sa.exc.StatementError,
+            exc.StatementError,
             "Can't resolve value for column users.id on object "
             ".User.*.; no value has been set for this column",
             q.one,
@@ -1440,7 +1482,7 @@ class DeferredRelationshipExpressionTest(_fixtures.FixtureTest):
 
         sess.expunge(u)
         assert_raises_message(
-            sa.exc.StatementError,
+            exc.StatementError,
             "Can't resolve value for column users.id on object "
             ".User.*.; the object is detached and the value was expired",
             q.one,
@@ -1690,7 +1732,7 @@ class WeakIdentityMapTest(_fixtures.FixtureTest):
         # already belongs to u2
         s2 = Session(testing.db)
         assert_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             r".*is already attached to session",
             s2.add,
             u1,
@@ -2035,7 +2077,7 @@ class SessionInterface(fixtures.MappedTest):
                 )
             else:
                 assert_raises(
-                    sa.exc.NoInspectionAvailable, callable_, *args, **kw
+                    exc.NoInspectionAvailable, callable_, *args, **kw
                 )
 
         raises_("connection", bind_arguments=dict(mapper=user_arg))
@@ -2062,7 +2104,7 @@ class SessionInterface(fixtures.MappedTest):
 
     def test_join_transaction_mode(self):
         with expect_raises_message(
-            sa.exc.ArgumentError,
+            exc.ArgumentError,
             'invalid selection for join_transaction_mode: "bogus"',
         ):
             Session(join_transaction_mode="bogus")
@@ -2116,7 +2158,7 @@ class SessionInterface(fixtures.MappedTest):
 
         with mock.patch.object(s, "_validate_persistent"):
             assert_raises_message(
-                sa.exc.ArgumentError,
+                exc.ArgumentError,
                 "with_for_update should be the boolean value True, "
                 "or a dictionary with options",
                 s.refresh,
@@ -2168,7 +2210,7 @@ class NewStyleExecutionTest(_fixtures.FixtureTest):
         getattr(sess, meth)()
 
         with expect_raises_message(
-            sa.exc.InvalidRequestError,
+            exc.InvalidRequestError,
             "Object .*User.* cannot be converted to 'persistent' state, "
             "as this identity map is no longer valid.",
         ):
@@ -2460,7 +2502,7 @@ class FlushWarningsTest(fixtures.MappedTest):
             object_session(instance).delete(Address(email="x1"))
 
         with expect_raises_message(
-            sa.exc.InvalidRequestError, ".*is not persisted"
+            exc.InvalidRequestError, ".*is not persisted"
         ):
             self._test(evt, r"Session.delete\(\)")