Skip to content

Commit

Permalink
Merge pull request #11 from DavidCEllis/expand_build_prefab
Browse files Browse the repository at this point in the history
Expand the build_prefab function to work with internals and support __slots__ classes.
  • Loading branch information
DavidCEllis authored May 15, 2024
2 parents 68c98ff + 9e49a8b commit dd4103e
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 12 deletions.
36 changes: 33 additions & 3 deletions src/ducktools/classbuilder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
# SOFTWARE.
import sys

__version__ = "v0.5.0"
__version__ = "v0.5.1"

# Change this name if you make heavy modifications
INTERNALS_DICT = "__classbuilder_internals__"
Expand Down Expand Up @@ -359,26 +359,49 @@ def from_field(cls, fld, /, **kwargs):
return cls(**argument_dict)


class GatheredFields:
__slots__ = ("fields", "modifications")

def __init__(self, fields, modifications):
self.fields = fields
self.modifications = modifications

def __call__(self, cls):
return self.fields, self.modifications


# Use the builder to generate __repr__ and __eq__ methods
# and pretend `Field` was a built class all along.
# for both Field and GatheredFields
_field_internal = {
"default": Field(default=NOTHING),
"default_factory": Field(default=NOTHING),
"type": Field(default=NOTHING),
"doc": Field(default=None),
}

_gathered_field_internal = {
"fields": Field(default=NOTHING),
"modifications": Field(default=NOTHING),
}

_field_methods = {repr_maker, eq_maker}
if _UNDER_TESTING:
_field_methods.update({frozen_setattr_maker, frozen_delattr_maker})

builder(
Field,
gatherer=lambda cls_: (_field_internal, {}),
gatherer=GatheredFields(_field_internal, {}),
methods=_field_methods,
flags={"slotted": True, "kw_only": True},
)

builder(
GatheredFields,
gatherer=GatheredFields(_gathered_field_internal, {}),
methods={repr_maker, eq_maker},
flags={"slotted": True, "kw_only": False},
)


# Slot gathering tools
# Subclass of dict to be identifiable by isinstance checks
Expand Down Expand Up @@ -430,6 +453,13 @@ def field_slot_gatherer(cls):
slot_replacement = {}

for k, v in cls_slots.items():
# Special case __dict__ and __weakref__
# They should be included in the final `__slots__`
# But ignored as a value.
if k in {"__dict__", "__weakref__"}:
slot_replacement[k] = None
continue

if isinstance(v, field_type):
attrib = v
if attrib.type is not NOTHING:
Expand Down
19 changes: 19 additions & 0 deletions src/ducktools/classbuilder/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,25 @@ class Field:
def from_field(cls, fld: Field, /, **kwargs: typing.Any) -> Field: ...


class GatheredFields:
__slots__ = ("fields", "modifications")

fields: dict[str, Field]
modifications: dict[str, typing.Any]

__classbuilder_internals__: dict

def __init__(
self,
fields: dict[str, Field],
modifications: dict[str, typing.Any]
) -> None: ...

def __repr__(self) -> str: ...
def __eq__(self, other) -> bool: ...
def __call__(self, cls: type) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...


class SlotFields(dict):
...

Expand Down
49 changes: 40 additions & 9 deletions src/ducktools/classbuilder/prefab.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

from . import (
INTERNALS_DICT, NOTHING,
Field, MethodMaker, SlotFields,
Field, MethodMaker, SlotFields, GatheredFields,
builder, fieldclass, get_flags, get_fields, make_slot_gatherer,
frozen_setattr_maker, frozen_delattr_maker, is_classvar,
)
Expand Down Expand Up @@ -519,6 +519,7 @@ def _make_prefab(
frozen=False,
dict_method=False,
recursive_repr=False,
gathered_fields=None,
):
"""
Generate boilerplate code for dunder methods in a class.
Expand All @@ -535,6 +536,7 @@ def _make_prefab(
such as lists)
:param dict_method: Include an as_dict method for faster dictionary creation
:param recursive_repr: Safely handle repr in case of recursion
:param gathered_fields: Pre-gathered fields callable, to skip re-collecting attributes
:return: class with __ methods defined
"""
cls_dict = cls.__dict__
Expand All @@ -546,12 +548,16 @@ def _make_prefab(
)

slots = cls_dict.get("__slots__")
if isinstance(slots, SlotFields):
gatherer = slot_prefab_gatherer
slotted = True
if gathered_fields is None:
if isinstance(slots, SlotFields):
gatherer = slot_prefab_gatherer
slotted = True
else:
gatherer = attribute_gatherer
slotted = False
else:
gatherer = attribute_gatherer
slotted = False
gatherer = gathered_fields
slotted = False if slots is None else True

methods = set()

Expand Down Expand Up @@ -770,6 +776,7 @@ def build_prefab(
frozen=False,
dict_method=False,
recursive_repr=False,
slots=False,
):
"""
Dynamically construct a (dynamic) prefab.
Expand All @@ -790,12 +797,35 @@ def build_prefab(
(This does not prevent the modification of mutable attributes such as lists)
:param dict_method: Include an as_dict method for faster dictionary creation
:param recursive_repr: Safely handle repr in case of recursion
:param slots: Make the resulting class slotted
:return: class with __ methods defined
"""
class_dict = {} if class_dict is None else class_dict
cls = type(class_name, bases, class_dict)
class_dict = {} if class_dict is None else class_dict.copy()

class_annotations = {}
class_slots = {}
fields = {}

for name, attrib in attributes:
setattr(cls, name, attrib)
if isinstance(attrib, Attribute):
fields[name] = attrib
elif isinstance(attrib, Field):
fields[name] = Attribute.from_field(attrib)
else:
fields[name] = Attribute(default=attrib)

if attrib.type is not NOTHING:
class_annotations[name] = attrib.type

class_slots[name] = attrib.doc

if slots:
class_dict["__slots__"] = class_slots

class_dict["__annotations__"] = class_annotations
cls = type(class_name, bases, class_dict)

gathered_fields = GatheredFields(fields, {})

cls = _make_prefab(
cls,
Expand All @@ -808,6 +838,7 @@ def build_prefab(
frozen=frozen,
dict_method=dict_method,
recursive_repr=recursive_repr,
gathered_fields=gathered_fields,
)

return cls
Expand Down
2 changes: 2 additions & 0 deletions src/ducktools/classbuilder/prefab.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def _make_prefab(
frozen: bool = False,
dict_method: bool = False,
recursive_repr: bool = False,
gathered_fields: Callable[[type], tuple[dict[str, Attribute], dict[str, typing.Any]]] | None = None,
) -> type: ...

_T = typing.TypeVar("_T")
Expand Down Expand Up @@ -146,6 +147,7 @@ def build_prefab(
frozen: bool = False,
dict_method: bool = False,
recursive_repr: bool = False,
slots: bool = False,
) -> type: ...

def is_prefab(o: typing.Any) -> bool: ...
Expand Down
22 changes: 22 additions & 0 deletions tests/prefab/dynamic/test_construction.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,25 @@ class KeywordPrefab:
a: int = 0
b: int = attribute(kw_only=True)
c: int = attribute() # kw_only should be ignored


def test_build_slotted():
SlottedClass = build_prefab(
"SlottedClass",
[
("x", attribute(doc="x co-ordinate", type=float)),
("y", attribute(default=0, doc="y co-ordinate", type=float))
],
slots=True,
)

inst = SlottedClass(1)
assert inst.x == 1
assert inst.y == 0
assert SlottedClass.__slots__ == {'x': "x co-ordinate", 'y': "y co-ordinate"}

assert SlottedClass.__annotations__ == {'x': float, 'y': float}

# Test slots are functioning
with pytest.raises(AttributeError):
inst.z = 0
81 changes: 81 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
slot_gatherer,
slotclass,
fieldclass,
GatheredFields,
)


Expand Down Expand Up @@ -299,6 +300,58 @@ class SlotClass:
assert "__eq__" not in SlotClass.__dict__


def test_slotclass_weakref():
import weakref

@slotclass
class WeakrefClass:
__slots__ = SlotFields(
a=1,
b=2,
__weakref__=None,
)

flds = get_fields(WeakrefClass)
assert 'a' in flds
assert 'b' in flds
assert '__weakref__' not in flds

slots = WeakrefClass.__slots__
assert 'a' in slots
assert 'b' in slots
assert '__weakref__' in slots

# Test weakrefs can be created
inst = WeakrefClass()
ref = weakref.ref(inst)
assert ref == inst.__weakref__


def test_slotclass_dict():
@slotclass
class DictClass:
__slots__ = SlotFields(
a=1,
b=2,
__dict__=None,
)

flds = get_fields(DictClass)
assert 'a' in flds
assert 'b' in flds
assert '__dict__' not in flds

slots = DictClass.__slots__
assert 'a' in slots
assert 'b' in slots
assert '__dict__' in slots

# Test if __dict__ is included new values can be added
inst = DictClass()
inst.c = 42
assert inst.__dict__ == {"c": 42}


def test_fieldclass():
@fieldclass
class NewField(Field):
Expand Down Expand Up @@ -374,3 +427,31 @@ class SlotClass:
assert x.a == 12
assert x.b == 2
assert x.c == []


def test_gatheredfields():
fields = {"x": Field(default=1)}
modifications = {"x": NOTHING}

alt_fields = {"x": Field(default=1), "y": Field(default=2)}

flds = GatheredFields(fields, modifications)
flds_2 = GatheredFields(fields, modifications)
flds_3 = GatheredFields(alt_fields, modifications)

class Ex:
pass

assert flds(Ex) == (fields, modifications)

assert flds == flds_2
assert flds != flds_3
assert flds != object()

assert repr(flds).endswith(
"GatheredFields("
"fields={'x': Field(default=1, default_factory=<NOTHING OBJECT>, type=<NOTHING OBJECT>, doc=None)}, "
"modifications={'x': <NOTHING OBJECT>}"
")"
)

0 comments on commit dd4103e

Please sign in to comment.