11

I'm following along with Architecture Patterns in Python by Harry Percival and Bob Gregory.

Around chapter three (3) they introduce testing the ORM of SQLAlchemy.

A new test that requires a session fixture, it is throwing AttributeError, FrozenInstanceError due to cannot assign to field '_sa_instance_state'

It may be important to note that other tests do not fail when creating instances of OrderLine, but they do fail if I simply include session into the test parameter(s).

Anyway I'll get straight into the code.

conftest.py

@pytest.fixture
def local_db():
    engine = create_engine('sqlite:///:memory:')
    metadata.create_all(engine)
    return engine


@pytest.fixture
def session(local_db):
    start_mappers()
    yield sessionmaker(bind=local_db)()
    clear_mappers()

model.py

@dataclass(frozen=True)
class OrderLine:
    id: str
    sku: str
    quantity: int

test_orm.py

def test_orderline_mapper_can_load_lines(session):
    session.execute(
        'INSERT INTO order_lines (order_id, sku, quantity) VALUES '
        '("order1", "RED-CHAIR", 12),'
        '("order1", "RED-TABLE", 13),'
        '("order2", "BLUE-LIPSTICK", 14)'
    )
    expected = [
        model.OrderLine("order1", "RED-CHAIR", 12),
        model.OrderLine("order1", "RED-TABLE", 13),
        model.OrderLine("order2", "BLUE-LIPSTICK", 14),
    ]
    assert session.query(model.OrderLine).all() == expected

Console error for pipenv run pytest test_orm.py

============================= test session starts =============================
platform linux -- Python 3.7.6, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /home/[redacted]/Documents/architecture-patterns-python
collected 1 item                                                              

test_orm.py F                                                           [100%]

================================== FAILURES ===================================
____________________ test_orderline_mapper_can_load_lines _____________________

session = <sqlalchemy.orm.session.Session object at 0x7fd919ac5bd0>

    def test_orderline_mapper_can_load_lines(session):
        session.execute(
            'INSERT INTO order_lines (order_id, sku, quantity) VALUES '
            '("order1", "RED-CHAIR", 12),'
            '("order1", "RED-TABLE", 13),'
            '("order2", "BLUE-LIPSTICK", 14)'
        )
        expected = [
>           model.OrderLine("order1", "RED-CHAIR", 12),
            model.OrderLine("order1", "RED-TABLE", 13),
            model.OrderLine("order2", "BLUE-LIPSTICK", 14),
        ]

test_orm.py:13: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
<string>:2: in __init__
    ???
../../.local/share/virtualenvs/architecture-patterns-python-Qi2y0bev/lib64/python3.7/site-packages/sqlalchemy/orm/instrumentation.py:377: in _new_state_if_none
    self._state_setter(instance, state)
<string>:1: in set
    ???
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <[AttributeError("'OrderLine' object has no attribute '_sa_instance_state'") raised in repr()] OrderLine object at 0x7fd919a8cf50>
name = '_sa_instance_state'
value = <sqlalchemy.orm.state.InstanceState object at 0x7fd9198f7490>

>   ???
E   dataclasses.FrozenInstanceError: cannot assign to field '_sa_instance_state'

<string>:4: FrozenInstanceError
=========================== short test summary info ===========================
FAILED test_orm.py::test_orderline_mapper_can_load_lines - dataclasses.Froze...
============================== 1 failed in 0.06s ==============================

Additional Questions

I understand the overlying logic and what these files are doing, but correct my if my rudimentary understanding is lacking.

  1. conftest.py (used for all pytest config) is setting up a session fixture, which basically sets up a temporary database in memory - using start and clear mappers to ensure that the orm model definitions are binding to the db isntance.
  2. model.py simply a dataclass used to represent an atomic OrderLine object.
  3. test_orm.py class for pytest to supply the session fixture, in order to setup, execute, teardown a db explicitly for the purpose of running tests.

Issue resolution provided by https://github.com/cosmicpython/code/issues/17

matabeitt
  • 160
  • 9
  • 1
    I see that in the `local_db()` fixture, you call `metadata.create_all(engine)`, so I assume that somewhere in your code you have a `Table("order_lines", metadata, ...)` call or a class somewhere like `class OrderLine(Base): ...`. If you created a `Table` object you need to [map `model.OrderLine` to the `Table`](https://docs.sqlalchemy.org/en/13/orm/mapping_styles.html#classical-mappings). If you have an ORM (`Base`) class, you need to use that to query the database through the session. Need more info, try to create a fully self enclosed example that reproduces the problem. – SuperShoot Apr 25 '20 at 03:30
  • However, having said that you need to map `model.OrderLine`, the fact that it is a frozen dataclass instance means that you can't add attributes to it and that is really what your error states: sqlalchemy is trying to treat it as a mapped class and mutate its state, which the frozen dataclass won't allow. That's why I'm asking for a more complete example, the error that is manifesting here isn't at the root of your problem. – SuperShoot Apr 25 '20 at 03:37
  • I agree with SuperShoot, the OrderLine object you're using shouldn't be a dataclass, it should be a child of [a declarative base](https://docs.sqlalchemy.org/en/13/orm/extensions/declarative/api.html#sqlalchemy.ext.declarative.declarative_base). – Arne Apr 27 '20 at 12:04
  • 7
    This is known issue. The author provide a fix for this problem on this issue: https://github.com/cosmicpython/code/issues/17 . You can't use `frozen=True` in your data classes. Not a complete answer, but it may help. – Renne Rocha Apr 27 '20 at 14:18

2 Answers2

3

SqlAlchemy allows you to override some of the attribute instrumentation that is applied when using mapping classes and tables. In particular the following allows sqla to save the state on an instrumented frozen dataclass. This should be applied before calling the mapper function which associates the dataclass and the sql table.

from sqlalchemy.ext.instrumentation import InstrumentationManager

...

DEL_ATTR = object()


class FrozenDataclassInstrumentationManager(InstrumentationManager):
    def install_member(self, class_, key, implementation):
        self.originals.setdefault(key, class_.__dict__.get(key, DEL_ATTR))
        setattr(class_, key, implementation)

    def uninstall_member(self, class_, key):
        original = self.originals.pop(key, None)
        if original is not DEL_ATTR:
            setattr(class_, key, original)
        else:
            delattr(class_, key)

    def dispose(self, class_):
        del self.originals
        delattr(class_, "_sa_class_manager")
    
    def manager_getter(self, class_):
        def get(cls):
            return cls.__dict__["_sa_class_manager"]
        return get

    def manage(self, class_, manager):
        self.originals = {}
        setattr(class_, "_sa_class_manager", manager)

    def get_instance_dict(self, class_, instance):
        return instance.__dict__

    def install_state(self, class_, instance, state):
        instance.__dict__["state"] = state

    def remove_state(self, class_, instance, state):
        del instance.__dict__["state"]

    def state_getter(self, class_):
        def find(instance):
            return instance.__dict__["state"]
        return find




OrderLine.__sa_instrumentation_manager__ = FrozenDataclassInstrumentationManager
TomDotTom
  • 6,238
  • 3
  • 41
  • 39
1

From version 1.14.16 the

def dispose(self, class_):

must be changed to

def unregister(self, class_, manager):

https://github.com/sqlalchemy/sqlalchemy/compare/rel_1_4_15...rel_1_4_16#diff-fc3d434dae3b60f8b2b448ee1e24165ffa71e75fbb2aeef1b4651e678a095be7R223

colidyre
  • 4,170
  • 12
  • 37
  • 53
flathill
  • 11
  • 2