Skip to content

Instantly share code, notes, and snippets.

@nrbnlulu
Last active August 3, 2022 14:01
Show Gist options
  • Save nrbnlulu/32feec9d60989ee34d0d0e5f02407e6e to your computer and use it in GitHub Desktop.
Save nrbnlulu/32feec9d60989ee34d0d0e5f02407e6e to your computer and use it in GitHub Desktop.
Qt Qml Generic Model
import typing
import attrs
from attrs import define, NOTHING, asdict
from typing import TypeVar, Any
from src.tools.utils import to_camel_case
from qtpy import QtCore as qtc
from src.tools.exceptions import ModelError
T = TypeVar("GenericType")
RoleDefined = TypeVar("RokeDefined")
UNSET = TypeVar("UNSET")
IS_GQL = "is_gql"
IS_ROLE = "is_role"
CAMEL_CASE = "camel_case"
OLD_NAME = "old_name"
MODEL_OBJ_ROLE = "model_obj"
def item_asdict(item):
ret = asdict(item)
ret.pop(MODEL_OBJ_ROLE)
return ret
def field_is(key: str, field):
if metadata := getattr(field, "metadata", False):
return metadata.get(key, False)
return False
def is_role_defined(type_):
return True if hasattr(type_, "__roles__") else False
def has_default_value(namespace, name):
# if the field has a default value it will appear in __dict__
return namespace.get(name, False)
@define
class Role:
"""
provides metadata about a field in a define_roles decorated class
"""
type: Any
num: int
name: str
python_name: str
qt_name: qtc.QByteArray
field: attrs.Attribute
is_child: bool = None
@classmethod
def from_field(cls, field: attrs.Attribute, role_num: int, python_name):
return cls(
type=field.type,
num=role_num, # not needed currently but saving here for any case
name=field.name,
qt_name=qtc.QByteArray(python_name.encode()),
python_name=python_name,
field=field,
is_child=is_role_defined(field.type),
)
@define
class RoleMapper:
"""
A container that maps the roles of a defined class
each mapp has a certain usage in the future.
"""
# this is the real name of the field
# how the class would be created
by_name: dict[str, Role] = attrs.field(factory=dict)
# number is a qt role 256+
# this is how qml will call the data() method
by_num: dict[int, Role] = attrs.field(factory=dict)
# just to return to qt method roleNames()
qt_roles: dict[int, qtc.QByteArray] = attrs.field(factory=dict)
# mapping all the roledefined to know for what
# to initialize a genericModel
children: dict[str, RoleDefined] = attrs.field(factory=dict)
def create_roles(cls: type, fields: list[attrs.Attribute]) -> list[attrs.Attribute]:
# inject model field
fields.append(
attrs.Attribute(
name=MODEL_OBJ_ROLE,
default=None,
validator=NOTHING,
repr=True,
cmp=True,
hash=True,
init=True,
inherited=False,
metadata={IS_ROLE: True, IS_GQL: False},
),
)
roles = RoleMapper()
for role_num, field in enumerate(fields, qtc.Qt.UserRole):
# assign role and check if not exists
python_name = field.name
if field_is(IS_ROLE, field):
if field_is(CAMEL_CASE, field) and '_' in python_name:
fields.remove(field)
field = field.evolve(name=to_camel_case(field.name))
fields.insert(role_num - qtc.Qt.UserRole, field)
role_ = Role.from_field(field, role_num, python_name)
# fill the role manager
roles.by_num[role_num] = role_
roles.by_name[role_.name] = role_
roles.qt_roles[role_.num] = role_.qt_name
if role_.is_child:
roles.children[role_.name] = role_
setattr(cls, "__roles__", roles)
return fields
def define_roles(cls, **kwargs):
cls = define(cls, field_transformer=create_roles)
if Model := getattr(cls, 'Model', False):
setattr(Model, '__roledefined_type__', cls)
else:
cls.Model = type("Model", (GenericModel,), {"__roledefined_type__": cls})
return cls
def role(
is_gql=True,
camel_case=True,
default=NOTHING,
validator=None,
repr=True,
eq=True,
order=None,
hash=None,
init=False,
metadata={},
converter=None,
):
return attrs.field(
default=default,
validator=validator,
repr=repr,
eq=eq,
order=order,
hash=hash,
init=init if init else is_gql,
metadata=metadata
if metadata
else {IS_GQL: is_gql, IS_ROLE: True, CAMEL_CASE: camel_case if is_gql else False},
converter=converter,
)
class GenericModel(qtc.QAbstractListModel):
"""
this class shouldn't be initiated directly
it should be subclassed for every RoleDefined class
because caching for all types is not wanted if a model provides
certain logic.
"""
#: __roledefined_type__ = my roledefined type
#: __roles__ all my roles
def __init__(self, query: list[dict], parent=None):
super().__init__(parent)
self.type_: Role = self.__roledefined_type__
self.roles: RoleMapper = self.type_.__roles__
self._data = []
self._initialize_data(query)
def _initialize_data(self, data: list[dict]):
self.layoutAboutToBeChanged.emit()
# search for children and initialize them as models
for node in data:
if children := self.roles.children:
for name, child in children.items():
child_data = node[name]
node[name] = child.type.Model(child_data, parent=self)
# initialize list of attrs classes
self._data = [self.type_(**node, model_obj=self) for node in data]
self.layoutChanged.emit()
def data(self, index: qtc.QModelIndex, role: int = ...):
if index.row() < len(self._data) and index.isValid():
try:
return getattr(self._data[index.row()], self.roles.by_num[role].name, None)
except KeyError:
# resolvers should be pre-evaluated when the model updated
raise ModelError(f"role {role} of type {self.type_} at index: [{index}] "
f"is not a valid role!\n"
f"options are: {self.roles.qt_roles}"
)
except IndexError:
raise IndexError(f"index [{index.row()}] of type {self.type_} could not be resolved"
f"max index is {self.rowCount()}")
def roleNames(self) -> typing.Dict:
return self.roles.qt_roles
def rowCount(self, parent: "qtc.QModelIndex" = ...) -> int:
return len(self._data)
@qtc.Slot(int, str, result='QVariant')
def get_by_index(self, row: int, key: str) -> dict:
return self.data(self.index(row), self.roles.by_name[key].num)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment