Search code examples
pythonjsonpython-attrs

Python attrs/cattrs serializing a dict using frozen attrs classes as the dict keys


I would like to structure and unstructure an attrs object, which includes dict fields that use simple frozen attrs for the dict keys. This works very well for objects created at runtime, but the frozen attribute fails to make un/structuring with cattrs easy.

This is a simple example of the problem:

import attr, cattr

# Simple attr that contains only a single primitive data type.
@attr.s(frozen=True)
class AbstractID:
    _id: Optional[int] = attr.ib()

    def __str__(self) -> str:
        if self._id is not None:
            return f"A{self._id}"
        else:
            return "—"


@attr.s(auto_attribs=True)
class Database:
    storage: dict[AbstractID, str] = {}

# Attempt to unstructure using cattrs
db = Database()
db.storage[AbstractID(1)] = "some data"

cattr.unstructure(db)

>>> TypeError: unhashable type: 'dict'

Is there some way to serialize the data, without using int or str as the dict keys, outside the import/export process? I saw that cattrs offers hooks to customize the serialization process, but I can't figure out how to reduce the AbstractID to an int when unstructuring, or how to structure it back into an AbstractID.

Can this be done?


Solution

  • The default approach fails since it's trying to generate:

    {"storage": {{"_id": 1}: "some_data"}
    

    And Python dicts don't support other dicts as keys.

    Since we'll be customizing behavior, we'll use a separate instance of a converter. I'll also be using the new attrs APIs since they're cleaner. Here's what you want to do:

    from typing import Optional
    
    from attr import define, frozen, Factory
    
    from cattr import GenConverter
    
    
    # Simple attr that contains only a single primitive data type.
    @frozen
    class AbstractID:
        _id: Optional[int]
    
        def __str__(self) -> str:
            if self._id is not None:
                return f"A{self._id}"
            else:
                return "—"
    
    
    @define
    class Database:
        storage: dict[AbstractID, str] = Factory(dict)
    
    
    # Attempt to unstructure using cattrs
    db = Database()
    db.storage[AbstractID(1)] = "some data"
    
    c = GenConverter()
    c.register_unstructure_hook(AbstractID, lambda aid: aid._id)
    c.register_structure_hook(AbstractID, lambda v, _: AbstractID(v))
    
    print(c.unstructure(db))  # {'storage': {1: 'some data'}}
    print(c.structure(c.unstructure(db), Database))  # Database(storage={AbstractID(_id=1): 'some data'})
    

    cattrs makes easy work of this stuff.