1

I'm trying to upgrade my Flask project from using SQLAlchemy 1.3 to 2.0. I'm following the guidance described here (https://docs.sqlalchemy.org/en/20/changelog/migration_20.html) I've gone through all steps will step 7 - upgraded SQLAlchemy to 1.4 including all dependencies, ensured that tests are successful and no legacy code warnings are there. Also I ensured the application itself is running well.

Then I've upgraded SQLAlchemy to 2.0 and upgraded all dependencies. Now, I've ensured the application runs well. But when I run tests I get errors of this kind:

_________________________________________________________ TestAdminRoleApi.test_get_roles
__________________________________________________________

self = <tests.users.test_role_api.TestAdminRoleApi testMethod=test_get_roles>

    def test_get_roles(self):
>       res = self.try_admin_operation(
            lambda: self.client.get('/api/v1/admin/user/role'))

tests/users/test_role_api.py:28:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ tests/__init__.py:51: in try_admin_operation
    res = operation() tests/users/test_role_api.py:29: in <lambda>
    lambda: self.client.get('/api/v1/admin/user/role')) ../sa2/lib/python3.12/site-packages/werkzeug/test.py:1162: in get
    return self.open(*args, **kw) ../sa2/lib/python3.12/site-packages/flask/testing.py:234: in open
    response = super().open( ../sa2/lib/python3.12/site-packages/werkzeug/test.py:1116: in open
    response_parts = self.run_wsgi_app(request.environ, buffered=buffered) ../sa2/lib/python3.12/site-packages/werkzeug/test.py:988: in run_wsgi_app
    rv = run_wsgi_app(self.application, environ, buffered=buffered) ../sa2/lib/python3.12/site-packages/werkzeug/test.py:1264: in run_wsgi_app
    app_rv = app(environ, start_response) ../sa2/lib/python3.12/site-packages/flask/app.py:1536: in __call__
    return self.wsgi_app(environ, start_response) ../sa2/lib/python3.12/site-packages/flask/app.py:1514: in wsgi_app
    response = self.handle_exception(e) ../sa2/lib/python3.12/site-packages/flask/app.py:1511: in wsgi_app
    response = self.full_dispatch_request() ../sa2/lib/python3.12/site-packages/flask/app.py:919: in full_dispatch_request
    rv = self.handle_user_exception(e) ../sa2/lib/python3.12/site-packages/flask/app.py:915: in full_dispatch_request
    rv = self.preprocess_request() ../sa2/lib/python3.12/site-packages/flask/app.py:1291: in preprocess_request
    rv = self.ensure_sync(before_func)() ../sa2/lib/python3.12/site-packages/flask_principal.py:479: in
_on_before_request
    self.set_identity(identity) ../sa2/lib/python3.12/site-packages/flask_principal.py:418: in set_identity
    self._set_thread_identity(identity) ../sa2/lib/python3.12/site-packages/flask_principal.py:462: in
_set_thread_identity
    identity_loaded.send(current_app._get_current_object(), ../sa2/lib/python3.12/site-packages/blinker/base.py:249: in send
    result = receiver(sender, **kwargs) ../sa2/lib/python3.12/site-packages/flask_security/core.py:739: in
_on_identity_loaded
    for role in getattr(current_user, "roles", []): ../sa2/lib/python3.12/site-packages/sqlalchemy/orm/attributes.py:566: in __get__
    return self.impl.get(state, dict_)  # type: ignore[no-any-return] ../sa2/lib/python3.12/site-packages/sqlalchemy/orm/attributes.py:1086: in get
    value = self._fire_loader_callables(state, key, passive) ../sa2/lib/python3.12/site-packages/sqlalchemy/orm/attributes.py:1121: in _fire_loader_callables
    return self.callable_(state, passive)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <sqlalchemy.orm.strategies.LazyLoader object at 0x10a68b220>, state = <sqlalchemy.orm.state.InstanceState object at 0x12d275cd0> passive = symbol('PASSIVE_OFF'), loadopt = None, extra_criteria = (), extra_options = (), alternate_effective_path = None execution_options
= immutabledict({})

    def _load_for_state(
        self,
        state,
        passive,
        loadopt=None,
        extra_criteria=(),
        extra_options=(),
        alternate_effective_path=None,
        execution_options=util.EMPTY_DICT,
    ):
        if not state.key and (
            (
                not self.parent_property.load_on_pending
                and not state._load_pending
            )
            or not state.session_id
        ):
            return LoaderCallableStatus.ATTR_EMPTY

        pending = not state.key
        primary_key_identity = None

        use_get = self.use_get and (not loadopt or not loadopt._extra_criteria)

        if (not passive & PassiveFlag.SQL_OK and not use_get) or (
            not passive & attributes.NON_PERSISTENT_OK and pending
        ):
            return LoaderCallableStatus.PASSIVE_NO_RESULT

        if (
            # we were given lazy="raise"
            self._raise_always
            # the no_raise history-related flag was not passed
            and not passive & PassiveFlag.NO_RAISE
            and (
                # if we are use_get and related_object_ok is disabled,
                # which means we are at most looking in the identity map
                # for history purposes or otherwise returning
                # PASSIVE_NO_RESULT, don't raise.  This is also a
                # history-related flag
                not use_get
                or passive & PassiveFlag.RELATED_OBJECT_OK
            )
        ):
            self._invoke_raise_load(state, passive, "raise")

        session = _state_session(state)
        if not session:
            if passive & PassiveFlag.NO_RAISE:
                return LoaderCallableStatus.PASSIVE_NO_RESULT

>           raise orm_exc.DetachedInstanceError(
                "Parent instance %s is not bound to a Session; "
                "lazy load operation of attribute '%s' cannot proceed"
                % (orm_util.state_str(state), self.key)
            ) E           sqlalchemy.orm.exc.DetachedInstanceError: Parent instance <User at 0x12d2b6b10> is not bound to a Session; lazy load operation of attribute 'roles' cannot proceed (Background on this error at: https://sqlalche.me/e/20/bhk3)

../sa2/lib/python3.12/site-packages/sqlalchemy/orm/strategies.py:922: DetachedInstanceError

The interesting thing is that if I run only one test file it runs well. Only the second and all following ones fail:

platform darwin -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/ralfeus/Projects/order
configfile: pytest.ini
plugins: env-1.1.5, mock-3.14.0
collected 5 items

tests/users/test_client.py ..
tests/users/test_role_api.py F
tests/users/test_user_api.py FF

Side note - the tester is pytest, however the test suits are using unittest due to historical reasons.

My setup (the base of test case class) is defined as following:

from typing import Optional
from unittest import TestCase

from sqlalchemy import text
from app import db, create_app

from app.users.models.user import User

app = create_app("../tests/config-test.json")
app.app_context().push()

class BaseTestCase(TestCase):
    user:Optional[User] = None
    admin:Optional[User] = None

    @classmethod
    def setUpClass(cls):
        db.session.execute(text('pragma foreign_keys=on'))

    def setUp(self):
        self.app = app
        self.client = self.app.test_client()
        self._ctx = self.app.test_request_context()
        self._ctx.push()
        self.maxDiff = None


    def tearDown(self):
        db.session.remove()
        db.drop_all()

    def try_admin_operation(self, operation, 
                            user_name:Optional[str]=None, user_password='1',
                            admin_name:Optional[str]=None, admin_password='1', admin_only=False):
        if user_name is None:
            user_name = self.user.username
        if admin_name is None:
            admin_name = self.admin.username
        res = operation()
        self.assertIn(res.status_code, [302, 403])
        if not admin_only:
            res = self.login(user_name, user_password)
            res = operation()
            self.assertEqual(res.status_code, 403)
            self.logout()
        self.login(admin_name, admin_password)
        return operation()

    def try_user_operation(self, operation, user_name=None, user_password='1'):
        if user_name is None:
            user_name = self.user.username
        res = operation()
        self.assertEqual(res.status_code, 302)
        self.login(user_name, user_password)
        return operation()

    def login(self, username, password):
        return self.client.post('/login', data=dict(
            username=username,
            password=password
        ))

    def logout(self):
        from flask_security import current_user
        current_user = None

    def try_add_entity(self, entity):
        try:
            db.session.add(entity)
            db.session.commit()
        except Exception as e:
            print(f'Exception while trying to add <{entity}>:', e)
            db.session.rollback()

And typical test looks like this:

from tests import BaseTestCase, db

from app.users.models.role import Role
from app.users.models.user import User

class TestAdminRoleApi(BaseTestCase):
    def setUp(self):
        super().setUp()
        self.maxDiff = None

        db.create_all()
        admin_role = Role(id=10, name='admin')
        self.admin = User(id=0, username='root', email='[email protected]',
            assword_hash='pbkdf2:sha256:150000$bwYY0rIO$',
            enabled=True, roles=[admin_role])
        self.user = User(id=10, username='user1', email='[email protected]',
            password_hash='pbkdf2:sha256:150000$bwYY0rIO$', 
            enabled=True)
        self.try_add_entities([
            admin_role, self.user, self.admin
        ])

    def test_get_roles(self):
        res = self.try_admin_operation(
            lambda: self.client.get('/api/v1/admin/user/role'))
        self.assertEqual(res.status_code, 200)
        self.assertEqual(len(res.json), 1)

My suspicion is that SQLAlchemy session of test case and the one of Flask application might be different and this might cause the issue. However several arguments are against this:

  • Flask-security (where the error occurs) reads users as a part of the application and should use single session of the Flask app
  • Before upgrade to 2.0 there were no issues

I dug the internet but found no issues related to migration 1.4->2.0 What did I miss?

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.