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 get(self, name: str, default: ClassMember | None = None) -> \
ClassMember | None:
return self._get_members().get(name, default)
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)