Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Unable to Instantiate Python Dataclass (Frozen) inside a Pytest function that uses Fixtures

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

like image 934
matabeitt Avatar asked Apr 25 '20 00:04

matabeitt


2 Answers

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
  • Attribute instrumentation docs
  • Custom instrumentation examples
like image 146
TomDotTom Avatar answered Nov 15 '22 20:11

TomDotTom


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
like image 1
flathill Avatar answered Nov 15 '22 20:11

flathill