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?