Source code for zensols.dataclasses.inspect

"""Very simple object relational mapping.

"""
__author__ = 'Paul Landes'
from typing import Any, get_type_hints
from collections.abc import Iterator, KeysView, ValuesView, ItemsView
from dataclasses import dataclass, field, Field
import dataclasses
import logging
import re
import ast
import inspect
from itertools import chain
import textwrap
from frozendict import frozendict
from ..config import Dictable
from ..persist import persisted
from ..introspect.imp import ClassImporter
from ..introspect.insp import ClassDoc
from ..introspect.insp import ClassField as IntrospectClassField

logger = logging.getLogger(__name__)


[docs] @dataclass(repr=False) class ClassMember(IntrospectClassField, Dictable): """Metadata that represents a member (dataclass field or property). """ @staticmethod def _get_dtype_str(dtype: type | None) -> str: if dtype is None: return '<none>' return getattr(dtype, '__name__', str(dtype)) @classmethod def _flatten_class_field(cls: type, data: dict[str, Any]): data['dtype'] = cls._get_dtype_str(data.get('dtype')) return data def _flatten_dict(self, data: dict[str, Any]) -> dict[str, Any]: data = self._flatten_class_field(data) data = super()._flatten_dict(data) return data def __str__(self) -> str: return f'{self.name} ({self._get_dtype_str(self.dtype)})' def __repr__(self) -> str: return self.__str__()
[docs] @dataclass(repr=False) class ClassField(ClassMember): """Metadata describing a dataclass field.""" data_field: Field = field(repr=False) """The field data from the dataclass."""
[docs] @dataclass(repr=False) class ClassProperty(ClassMember): """Metadata describing a class property.""" prop: property = field(repr=False) """The class property from the dataclass."""
[docs] @dataclass class DataclassMetadata(Dictable): """Introspects a dataclass and exposes metadata about its members. This class provides a unified metadata view of the fields and properties defined by :attr:`class_type`. Dataclass fields are represented by :class:`ClassField` instances and Python properties by :class:`ClassProperty` instances. Each member records its name, declared type, and documentation when available. Field documentation is obtained from string literals immediately following field declarations in the class body. Property documentation is obtained from the property's getter docstring, and its type is inferred from the getter's return annotation. The instance also provides a read-only mapping-like interface from member names to :class:`ClassMember` metadata. Iteration yields dataclass fields followed by properties. """ _DICTABLE_ATTRIBUTES = {'fields_by_order', 'properties_by_order', 'doc'} class_type: type = field() """the dataclass type to introspect.""" def _flatten_dict(self, data: dict[str, Any]) -> dict[str, Any]: data['fields_by_order'] = tuple(map( ClassMember._flatten_class_field, data['fields_by_order'])) data['properties_by_order'] = tuple(map( ClassMember._flatten_class_field, data['properties_by_order'])) data['class_type'] = ClassImporter.full_classname(self.class_type) dct = super()._flatten_dict(data) return dct def _get_attribute_docstrings(self) -> dict[str, str]: cls: type = self.class_type source = inspect.getsource(cls) source = textwrap.dedent(source) tree = ast.parse(source) class_node = next( node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == cls.__name__) docs: dict[str, str] = {} prev_field_name: str = None for node in class_node.body: if isinstance(node, ast.AnnAssign): # name: str = field() # icon: str if isinstance(node.target, ast.Name): prev_field_name = node.target.id else: prev_field_name = None elif isinstance(node, ast.Assign): # name = field() if len(node.targets) == 1 and \ isinstance(node.targets[0], ast.Name): prev_field_name = node.targets[0].id else: prev_field_name = None elif (prev_field_name is not None and isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)): doc: str = inspect.cleandoc(node.value.value) doc = re.sub(r'\s+', ' ', doc).strip() docs[prev_field_name] = doc prev_field_name = None else: prev_field_name = None return docs @property @persisted('_fields_by_order') def fields_by_order(self) -> tuple[ClassField, ...]: """Create field metadata for :obj:`class_type`.""" def map_field(f: Field) -> ClassField: doc: str = docs.get(f.name) return ClassField( name=f.name, dtype=f.type, doc=None if doc is None else ClassDoc(doc), kwargs={}, data_field=f) docs: dict[str, str] = self._get_attribute_docstrings() return tuple(map(map_field, dataclasses.fields(self.class_type))) @property @persisted('_fields') def fields(self) -> dict[str, ClassField]: """The fields of :obj:`class_type`.""" return frozendict(map(lambda m: (m.name, m), self.fields_by_order)) @property @persisted('_properties_by_order') def properties_by_order(self) -> tuple[ClassProperty, ...]: """Property metadata for :obj:`class_type`.""" def map_prop(tup: tuple[str, property]) -> ClassProperty: name, prop = tup doc: str | None = prop.__doc__ dtype: type | None = None if prop.fget is None \ else get_type_hints(prop.fget).get('return') return ClassProperty( name=name, dtype=dtype, doc=None if doc is None else ClassDoc(doc), kwargs={}, prop=prop) return tuple(map(map_prop, filter( lambda i: isinstance(i[1], property), self.class_type.__dict__.items()))) @property @persisted('_properties') def properties(self) -> dict[str, ClassProperty]: """The properties of :obj:`class_type`.""" return frozendict(map(lambda p: (p.name, p), self.properties_by_order)) @persisted('_members') def _get_members(self) -> dict[str, ClassMember]: return frozendict(self.fields | self.properties) @property @persisted('_doc') def doc(self) -> ClassDoc: """The docstring of the class.""" return ClassDoc(self.class_type.__doc__ or '')
[docs] def keys(self) -> KeysView[str]: return self._get_members().keys()
[docs] def values(self) -> ValuesView[ClassMember]: return self._get_members().values()
[docs] def get(self, name: str, default: ClassMember | None = None) -> \ ClassMember | None: return self._get_members().get(name, default)
[docs] def items(self) -> ItemsView[str, ClassMember]: return self._get_members.items()
def __iter__(self) -> Iterator[ClassMember]: return chain(self.fields_by_order, self.properties_by_order) def __getitem__(self, name: str) -> ClassMember: return self._get_members()[name] def __len__(self) -> int: return len(self._get_members()) def _write(self, c): c(str(self.class_type), 'class_type') c(self.doc.text, 'doc') meta: ClassMember for meta in self: fd: dict[str, Any] = meta.asdict() c(f"{fd.pop('name')}:") c(fd, depth=1)