Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expand the build_prefab function #11

Merged
merged 7 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>}"
")"
)