Last active
August 3, 2022 14:01
-
-
Save nrbnlulu/32feec9d60989ee34d0d0e5f02407e6e to your computer and use it in GitHub Desktop.
Qt Qml Generic Model
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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