Source code for prettyclass.pp

# -*- coding: utf-8 -*-

# prettyclass
# -----------
# pretty classes - pretty easy.  (created by auxilium)
#
# Author:   sonntagsgesicht
# Version:  0.1, copyright Monday, 07 October 2024
# Website:  https://github.com/sonntagsgesicht/prettyclass
# License:  Apache License 2.0 (see LICENSE file)


from inspect import stack, signature
from json import loads, dumps
from os import linesep as _sep
from typing import Type


def _init_super(cls: Type):
    parameters = []
    for k, p in signature(cls).parameters.items():
        if k == 'self':
            continue
        match p.kind:
            case 1:
                parameters.append(k)
            case 2:
                parameters.append('*' + k)
            case 3:
                parameters.append(k + '=' + k)
            case 4:
                parameters.append('**' + k)
    return f"__init__({' ,'.join(parameters)})"


def _add_init(cls: Type):
    # build __init__ as source code text
    sig = signature(cls.__init__)
    s = [f"def __init__{sig}:"]
    s += [f"    self.{p} = {p}" for p in sig.parameters if not p == 'self']
    s += [f"    _super(self).{_init_super(cls)}"]

    # set state and build function
    _globals = {'_super': lambda self: super(cls, self)}
    exec(_sep.join(s), _globals, _globals)  # nosec B102

    # And finally create the class.
    qualname = getattr(cls, '__qualname__', None)
    cls_doc = getattr(cls, '__doc__', None)
    init_doc = getattr(cls.__init__, '__doc__', None)

    # create subclass (similar to datasclasse._add_slots)
    cls_dict = dict(cls.__dict__)
    cls_dict['__init__'] = _globals.get('__init__')
    cls = type(cls)(cls.__name__, (cls,), cls_dict)

    if qualname is not None:
        cls.__qualname__ = qualname
    if cls_doc is not None:
        cls.__doc__ = cls_doc
    if init_doc is not None:
        cls.__init__.__doc__ = init_doc

    return cls


def _add_slots(cls, is_frozen=False):
    # Need to create a new class, since we can't set __slots__
    #  after a class has been created.

    # Make sure __slots__ isn't already set.
    if '__slots__' in cls.__dict__:
        raise TypeError(f'{cls.__name__} already specifies __slots__')

    # Create a new dict for our new class.
    cls_dict = dict(cls.__dict__)
    field_names = tuple(f for f in signature(cls).parameters)
    cls_dict['__slots__'] = field_names
    for field_name in field_names:
        # Remove our attributes, if present. They'll still be
        #  available in _MARKER.
        cls_dict.pop(field_name, None)

    # Remove __dict__ itself.
    cls_dict.pop('__dict__', None)

    # And finally create the class.
    qualname = getattr(cls, '__qualname__', None)
    cls = type(cls)(cls.__name__, cls.__bases__, cls_dict)
    if qualname is not None:
        cls.__qualname__ = qualname

    # _dataclass_getstate and _dataclass_setstate
    # are needed for pickling frozen
    # classes with slots.  These could be slightly
    # more performant if we generated
    # the code instead of iterating over fields.
    # But that can be a project for
    # another day, if performance becomes an issue.
    def _dataclass_getstate(self):
        return [getattr(self, f) for f in field_names]

    def _dataclass_setstate(self, state):
        for field, value in zip(field_names, state):
            # use setattr because dataclass may be frozen
            object.__setattr__(self, field, value)

    if is_frozen:
        # Need this for pickling frozen classes with slots.
        cls.__getstate__ = _dataclass_getstate
        cls.__setstate__ = _dataclass_setstate

    return cls


def _fields(self):
    s = signature(self.__class__)
    kpv = ((k, p, getattr(self, k, p.default))
           for k, p in s.parameters.items())
    return {k: v for k, p, v in kpv if not v == p.default}


def _bound_arguments(self):
    # scan attributes used as arguments
    s = signature(self.__class__)
    kpv = ((k, p, getattr(self, k, p.default))
           for k, p in s.parameters.items())
    kv = {k: v for k, p, v in kpv if not v == p.default}
    # use function name rather than repr string
    # kv = {k: getattr(v, '__qualname__', v) for k, v in kv.items()}
    b = s.bind(**kv)
    args, kwargs = b.args, b.kwargs

    var_p = [k for k, v in b.signature.parameters.items() if v.kind == 2]
    if 1 < len(var_p):
        raise RuntimeError('found more than one var positional argument')
    var_p = kwargs.pop(var_p[0], ()) if var_p else ()
    args += var_p

    var_kw = [k for k, v in b.signature.parameters.items() if v.kind == 4]
    if 1 < len(var_kw):
        raise RuntimeError('found more than one var keyword only argument')
    var_kw = kwargs.pop(var_kw[0], {}) if var_kw else {}
    kwargs.update(var_kw)

    return args, kwargs


def _setstate(self, state):
    for p, v in state.items():
        setattr(self, p, v)


def _default(self):
    return self.__json__() if hasattr(self, '__json__') else repr(self)


def _dumps(self):
    """dump instance to json via signature arguments pretty easy"""
    args, kwargs = _bound_arguments(self)
    args = [getattr(a, '__qualname__', a) for a in args]
    kwargs = {k: getattr(v, '__qualname__', v) for k, v in kwargs.items()}
    d = self.__class__.__qualname__, args, kwargs
    return dumps(d, indent=2, default=_default)


def _loads(s, globals=()):
    globals = globals or globals()
    d = loads(s)
    if not isinstance(d, (list, tuple)):
        raise ValueError("no valid object structure found")
    if len(d) == 3:
        # ['PrettyClass', [1, 2, 3], {'a': 0}]
        cls, args, kwargs = d
    elif len(d) == 2:
        # ['PrettyClass', [1, 2, 3]]
        cls, args = d
        kwargs = {}
        if isinstance(args, dict):
            # ['PrettyClass', {'a': 0}]
            args, kwargs = [], args
    else:
        raise ValueError("no valid object structure found")

    cls = globals.get(cls)

    # replace existing global objects like functions or classes
    # or _load PrettyClasses
    def update(k, v, container):
        if isinstance(v, str) and v in globals:
            # replace existing global objects
            container[k] = globals.get(v)
        if (isinstance(v, list) and v and v[0] in globals) or \
                (isinstance(v, dict) and 'type' in a and v['type'] in globals):
            # _load PrettyClasses
            try:
                container[k] = _loads(v, globals)
            except ValueError:
                pass

    for i, a in enumerate(args):
        update(i, a, args)
    for k, v in kwargs.items():
        update(k, v, kwargs)

    return cls(*args, **kwargs)


def _from_json(cls, s):
    """load instance from json via signature arguments pretty easy"""
    objs = globals()
    objs[cls.__qualname__] = cls
    return _loads(s, globals=objs)


def _copy(self):
    """copy instance via signature arguments pretty easy"""
    args, kwargs = _bound_arguments(self)
    return self.__class__(*args, **kwargs)


def _eq(self, other):
    """compares instance via signature arguments pretty easy"""
    args, kwargs = _bound_arguments(self)
    orgs, kworgs = _bound_arguments(other)
    return (all(a == o for a, o in zip(args, orgs)) and
            all(kwargs[k] == kworgs[k] for k in set(kwargs).union(kworgs)))


def _bool(self):
    """decides bool value of instance via signature arguments pretty easy"""
    args, kwargs = _bound_arguments(self)
    return all(map(bool, args)) and all(map(bool, kwargs.values()))


def _hash(self):
    """hash instance via signature arguments pretty easy"""
    return hash(repr(self.__dict__))


def _r(self):
    return str if any(f.function == '__str__' for f in stack()) else repr


def _pp(self, *, r=None, sep=', '):
    r = r or _r(self)
    args, kwargs = _bound_arguments(self)
    args = [getattr(a, '__qualname__', r(a)) for a in args]
    kwargs = {k: getattr(v, '__qualname__', r(v)) for k, v in kwargs.items()}
    params = [f"{a}" for a in args] + [f"{k}={v}" for k, v in kwargs.items()]
    return f"{self.__class__.__qualname__}({sep.join(params)})"


def _repr(self):
    """repr instance via signature arguments pretty easy"""
    return _pp(self, r=repr)


def _str(self):
    """str instance via signature arguments pretty easy"""
    return _pp(self, r=str)


def _process(cls, init, repr, copy, eq, nonzero, hash, json):
    if init:
        cls = _add_init(cls)
    if repr:
        setattr(cls, '__str__', _str)
        setattr(cls, '__repr__', _repr)
    if copy:
        setattr(cls, '__copy__', _copy)
        setattr(cls, '__setstate__', _setstate)
    if eq:
        setattr(cls, '__eq__', _eq)
    if nonzero:
        setattr(cls, '__bool__', _bool)
    if hash:
        setattr(cls, '__hash__', _hash)
    if json:
        setattr(cls, '__json__', _dumps)
        setattr(cls, 'from_json', classmethod(_from_json))
    return cls


[docs] def prettyclass(cls: Type = None, *, init: bool = True, repr: bool = True, copy: bool = True, eq: bool = False, nonzero: bool = False, hash: bool = False, json: bool = False): """pretty class decorator Returns the same class as was passed in, with dunder methods added based on the fields defined in the '__init__' signature . :param cls: class to add methods :param init: add '__init__' method body by storing all arguments of its signature, so they are available for the added methods but are not needed to be implemented (optional; default is **True**) :param repr: bool to add '__repr__' and '__str__' (optional; default is **True**) :param copy: bool to add '__copy__' as well as ability to pickle (optional; default is **True**) :param eq: bool to add '__eq__' which then returns **True** if all arguments fields are equal (optional; default is **False**) :param nonzero: bool to add '__bool__' which then returns **True** if all arguments fields are **True** (optional; default is **False**) :param hash: bool to add '__hash__' which then returns `hash(repr(self.__dict__))` (optional; default is **False**) :param json: bool to add '__json__' and classmethod 'from_json' to serialize and de-serialize json strings (optional; default is **False**) :return: same class It is recommended to avoid any further attributes which define the state of an instance. Otherwise, the instance replication would not be ensured. >>> from prettyclass import prettyclass >>> @prettyclass() ... class ABC: ... def __init__(self, a, *b, c, d=1, e, **f): ... '''creates ABC instance''' The decorator adds automaticly all argument fields as attributes. >>> abc = ABC(1, 2, [3, 4], c=5, d=6, e=7, g='A', h='B') >>> abc.__dict__ {'a': 1, 'b': (2, [3, 4]), 'c': 5, 'd': 6, 'e': 7, 'f': {'g': 'A', 'h': 'B'}} >>> abc.a, abc.b, abc.c, abc.d, abc.e, abc.f (1, (2, [3, 4]), 5, 6, 7, {'g': 'A', 'h': 'B'}) and returns a pretty nice representation of an instance >>> abc ABC(1, 2, [3, 4], c=5, d=6, e=7, g='A', h='B') Note the difference between 'str' and 'repr' >>> str(abc) 'ABC(1, 2, [3, 4], c=5, d=6, e=7, g=A, h=B)' >>> repr(abc) "ABC(1, 2, [3, 4], c=5, d=6, e=7, g='A', h='B')" Copy works by default, too. >>> from copy import copy >>> copy(abc) ABC(1, 2, [3, 4], c=5, d=6, e=7, g='A', h='B') Same for pickle. >>> from pickle import dumps, loads >>> s = dumps(abc) # doctest: +SKIP >>> loads(s) # doctest: +SKIP ABC(1, 2, [3, 4], c=5, d=6, e=7, g='A', h='B') Note, the string representation ommitts entries which coincide with defaults >>> ABC(1, 2, [3, 4], c=5, d=1, e=7, g='A', h='B') ABC(1, 2, [3, 4], c=5, e=7, g='A', h='B') """ # noqa E501 def wrap(cls): return _process(cls, init, repr, copy, eq, nonzero, hash, json) # See if we're being called as @dataclass or @dataclass(). if cls is None: # We're called with parens. return wrap # We're called as @dataclass without parens. return wrap(cls)