diff --git a/docs/code_examples/docs_ex02_register.py b/docs/code_examples/docs_ex02_register.py index c407b64..ef2ca75 100644 --- a/docs/code_examples/docs_ex02_register.py +++ b/docs/code_examples/docs_ex02_register.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from ducktools.classbuilder import slotclass, SlotFields +from ducktools.classbuilder.prefab import Prefab class_register = {} @@ -9,6 +9,7 @@ def register(cls): return cls +# Order is important here @dataclass(slots=True) @register class DataCoords: @@ -16,13 +17,10 @@ class DataCoords: y: float = 0.0 -@slotclass @register -class SlotCoords: - __slots__ = SlotFields(x=0.0, y=0.0) - # Type hints don't affect class construction, these are optional. - x: float - y: float +class SlotCoords(Prefab): + x: float = 0.0 + y: float = 0.0 print(DataCoords()) diff --git a/docs/code_examples/docs_ex08_converters.py b/docs/code_examples/docs_ex08_converters.py index 8dd2c94..ff7960b 100644 --- a/docs/code_examples/docs_ex08_converters.py +++ b/docs/code_examples/docs_ex08_converters.py @@ -1,18 +1,20 @@ from ducktools.classbuilder import ( - builder, - default_methods, + add_methods, get_fields, - slot_gatherer, - Field, + make_unified_gatherer, + print_generated_code, GeneratedCode, - SlotFields, MethodMaker, ) +from ducktools.classbuilder.prefab import attribute, Attribute, Prefab -class ConverterField(Field): - converter = Field(default=None) +class ConverterAttribute(Attribute): + converter = attribute(default=None) +# This makes the internal field instances into `ConverterAttribute` instead of `Attribute` +# which would be the default for `prefab` +gatherer = make_unified_gatherer(field_type=ConverterAttribute) def setattr_generator(cls, funcname="__setattr__"): fields = get_fields(cls) @@ -38,20 +40,20 @@ def setattr_generator(cls, funcname="__setattr__"): setattr_maker = MethodMaker("__setattr__", setattr_generator) -methods = frozenset(default_methods | {setattr_maker}) +extra_methods = {setattr_maker} -def converterclass(cls, /): - return builder(cls, gatherer=slot_gatherer, methods=methods) +class ConverterClass(Prefab, gatherer=gatherer): + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + add_methods(cls, extra_methods) if __name__ == "__main__": - @converterclass - class ConverterEx: - __slots__ = SlotFields( - unconverted=ConverterField(), - converted=ConverterField(converter=int), - ) + class ConverterEx(ConverterClass): + unconverted: str + converted: int = ConverterAttribute(converter=int) ex = ConverterEx("42", "42") print(ex) + print_generated_code(ConverterEx) diff --git a/docs/code_examples/docs_ex09_annotated.py b/docs/code_examples/docs_ex09_annotated.py index 64bb8bd..9d8db5b 100644 --- a/docs/code_examples/docs_ex09_annotated.py +++ b/docs/code_examples/docs_ex09_annotated.py @@ -1,22 +1,18 @@ # Don't use __future__ annotations with get_ns_annotations in this case # as it doesn't evaluate string annotations. -# NOTE: In Python 3.14 this will currently only work if there are *no* forward references. - +import sys import types +from functools import wraps from typing import Annotated, Any, ClassVar, get_origin from ducktools.classbuilder import ( - builder, - default_methods, - get_fields, get_methods, - Field, - SlotMakerMeta, NOTHING, ) +from ducktools.classbuilder.prefab import prefab, Prefab, Attribute, attribute, get_attributes -from ducktools.classbuilder.annotations import get_ns_annotations +from ducktools.classbuilder.annotations import get_ns_annotations, is_classvar, resolve_type # Our 'Annotated' tools need to be combinable and need to contain the keyword argument @@ -48,6 +44,7 @@ def __eq__(self, other): NO_INIT = FieldModifier(init=False) NO_REPR = FieldModifier(repr=False) NO_COMPARE = FieldModifier(compare=False) +NO_SERIALIZE = FieldModifier(serialize=False) IGNORE_ALL = FieldModifier(init=False, repr=False, compare=False) @@ -68,6 +65,12 @@ def annotated_gatherer(cls_or_ns): for key, anno in cls_annotations.items(): modifiers = {} + # Under Python 3.14 these may be `DeferredAnnotations` + # Resolve them to ForwardRefs + anno = resolve_type(anno) + if is_classvar(anno): + continue + if get_origin(anno) is Annotated: meta = anno.__metadata__ for v in meta: @@ -78,22 +81,19 @@ def annotated_gatherer(cls_or_ns): # Extract the actual annotation from the first argument anno = anno.__origin__ - if anno is ClassVar or get_origin(anno) is ClassVar: - continue - if key in cls_dict: val = cls_dict[key] - if isinstance(val, Field): + if isinstance(val, Attribute): # Make a new field - DO NOT MODIFY FIELDS IN PLACE - fld = Field.from_field(val, type=anno, **modifiers) + fld = Attribute.from_field(val, type=anno, **modifiers) cls_modifications[key] = NOTHING elif not isinstance(val, types.MemberDescriptorType): - fld = Field(default=val, type=anno, **modifiers) + fld = Attribute(default=val, type=anno, **modifiers) cls_modifications[key] = NOTHING else: - fld = Field(type=anno, **modifiers) + fld = Attribute(type=anno, **modifiers) else: - fld = Field(type=anno, **modifiers) + fld = Attribute(type=anno, **modifiers) cls_fields[key] = fld @@ -101,36 +101,14 @@ def annotated_gatherer(cls_or_ns): # As a decorator -def annotatedclass(cls=None, *, kw_only=False): - if not cls: - return lambda cls_: annotatedclass(cls_, kw_only=kw_only) - - return builder( - cls, - gatherer=annotated_gatherer, - methods=default_methods, - flags={"slotted": False, "kw_only": kw_only} - ) +@wraps(prefab) +def annotatedclass(cls=None, **kwargs): + return prefab(cls, gatherer=annotated_gatherer, **kwargs) # As a base class with slots -class AnnotatedClass(metaclass=SlotMakerMeta, gatherer=annotated_gatherer): - - def __init_subclass__(cls, kw_only=False, **kwargs): - slots = "__slots__" in cls.__dict__ - - # if slots is True then fields will already be present in __slots__ - # Use the slot_gatherer for this case - gatherer = annotated_gatherer - - builder( - cls, - gatherer=gatherer, - methods=default_methods, - flags={"slotted": slots, "kw_only": kw_only} - ) - - super().__init_subclass__(**kwargs) +class AnnotatedClass(Prefab, gatherer=annotated_gatherer): + pass if __name__ == "__main__": @@ -144,9 +122,12 @@ class X: z: Annotated[ClassVar[str], "Should be ignored"] = "This should also be ignored" # type: ignore a: Annotated[int, NO_INIT] = "Not In __init__ signature" # type: ignore b: Annotated[str, NO_REPR] = "Not In Repr" - c: Annotated[list[str], NO_COMPARE] = Field(default_factory=list) # type: ignore + c: Annotated[list[str], NO_COMPARE] = attribute(default_factory=list) # type: ignore d: Annotated[str, IGNORE_ALL] = "Not Anywhere" e: Annotated[str, KW_ONLY, NO_COMPARE] + if sys.version_info >= (3, 14): + # Forward references work in 3.14 + f: Annotated[unknown, NO_COMPARE, NO_SERIALIZE] = 42 class Y(AnnotatedClass): @@ -155,16 +136,17 @@ class Y(AnnotatedClass): z: Annotated[ClassVar[str], "Should be ignored"] = "This should also be ignored" # type: ignore a: Annotated[int, NO_INIT] = "Not In __init__ signature" # type: ignore b: Annotated[str, NO_REPR] = "Not In Repr" - c: Annotated[list[str], NO_COMPARE] = Field(default_factory=list) # type: ignore + c: Annotated[list[str], NO_COMPARE] = attribute(default_factory=list) # type: ignore d: Annotated[str, IGNORE_ALL] = "Not Anywhere" e: Annotated[str, KW_ONLY, NO_COMPARE] - + if sys.version_info >= (3, 14): + f: Annotated[unknown, NO_COMPARE, NO_SERIALIZE] = 42 # Unslotted Demo ex = X("Value of x", e="Value of e") # type: ignore print(ex, "\n") - pp(get_fields(X)) + pp(get_attributes(X)) print("\n") # Slotted Demo @@ -183,6 +165,6 @@ class Y(AnnotatedClass): # Both classes generate identical source code genX = method.code_generator(X) genY = method.code_generator(Y) - assert genX == genY + assert genX.source_code == genY.source_code print(genX.source_code) diff --git a/docs/code_examples/outputs/docs_ex01_basic.output b/docs/code_examples/outputs/docs_ex01_basic.output index ffc4abc..6a49d81 100644 --- a/docs/code_examples/outputs/docs_ex01_basic.output +++ b/docs/code_examples/outputs/docs_ex01_basic.output @@ -15,6 +15,8 @@ class Slotted(ducktools.classbuilder.prefab.Prefab) | | Methods defined here: | + | __classbuilder_meta_gatherer__ = field_unified_gatherer(cls_or_ns) from ducktools.classbuilder.make_unified_gatherer. + | | __eq__(self, other) from Slotted | | __init__( @@ -72,4 +74,5 @@ class Slotted(ducktools.classbuilder.prefab.Prefab) | :param ignore_annotations: Ignore type annotations when gathering fields, only look for | slots or attribute(...) values | :param slots: automatically generate slots for this class's attributes + | :param gatherer: A gatherer to use for collecting `Attribute`s diff --git a/docs/code_examples/outputs/docs_ex08_converters.output b/docs/code_examples/outputs/docs_ex08_converters.output index c68a855..1d0eebb 100644 --- a/docs/code_examples/outputs/docs_ex08_converters.output +++ b/docs/code_examples/outputs/docs_ex08_converters.output @@ -1 +1,32 @@ ConverterEx(unconverted='42', converted=42) +Source: + def __eq__(self, other): + return ( + self.unconverted == other.unconverted + and self.converted == other.converted + ) if self.__class__ is other.__class__ else NotImplemented + + def __init__(self, unconverted, converted): + self.unconverted = unconverted + self.converted = converted + + def __replace__(self, /, **changes): + new_kwargs = {'unconverted': self.unconverted, 'converted': self.converted} + new_kwargs |= changes + return self.__class__(**new_kwargs) + + def __repr__(self): + return f'{type(self).__qualname__}(unconverted={self.unconverted!r}, converted={self.converted!r})' + + def __setattr__(self, name, value): + if conv := _converters.get(name): + _object_setattr(self, name, conv(value)) + else: + _object_setattr(self, name, value) + + +Globals: + __setattr__: {'_converters': {'converted': }, '_object_setattr': } + +Annotations: + __init__: {'unconverted': , 'converted': , 'return': None} diff --git a/docs/code_examples/outputs/docs_ex09_annotated.output b/docs/code_examples/outputs/docs_ex09_annotated.output index 7f11263..2e1db9b 100644 --- a/docs/code_examples/outputs/docs_ex09_annotated.output +++ b/docs/code_examples/outputs/docs_ex09_annotated.output @@ -1,16 +1,17 @@ -X(x='Value of x', a='Not In __init__ signature', c=[], e='Value of e') + -{'x': Field(default=, default_factory=, type=, doc=None, init=True, repr=True, compare=True, kw_only=False), - 'a': Field(default='Not In __init__ signature', default_factory=, type=, doc=None, init=False, repr=True, compare=True, kw_only=False), - 'b': Field(default='Not In Repr', default_factory=, type=, doc=None, init=True, repr=False, compare=True, kw_only=False), - 'c': Field(default=, default_factory=, type=list[str], doc=None, init=True, repr=True, compare=False, kw_only=False), - 'd': Field(default='Not Anywhere', default_factory=, type=, doc=None, init=False, repr=False, compare=False, kw_only=False), - 'e': Field(default=, default_factory=, type=, doc=None, init=True, repr=True, compare=False, kw_only=True)} +{'x': Attribute(default=, default_factory=, type=, doc=None, init=True, repr=True, compare=True, kw_only=False, iter=True, serialize=True, metadata={}), + 'a': Attribute(default='Not In __init__ signature', default_factory=, type=, doc=None, init=False, repr=True, compare=True, kw_only=False, iter=True, serialize=True, metadata={}), + 'b': Attribute(default='Not In Repr', default_factory=, type=, doc=None, init=True, repr=False, compare=True, kw_only=False, iter=True, serialize=True, metadata={}), + 'c': Attribute(default=, default_factory=, type=list[str], doc=None, init=True, repr=True, compare=False, kw_only=False, iter=True, serialize=True, metadata={}), + 'd': Attribute(default='Not Anywhere', default_factory=, type=, doc=None, init=False, repr=False, compare=False, kw_only=False, iter=True, serialize=True, metadata={}), + 'e': Attribute(default=, default_factory=, type=, doc=None, init=True, repr=True, compare=False, kw_only=True, iter=True, serialize=True, metadata={}), + 'f': Attribute(default=42, default_factory=, type=ForwardRef('unknown'), doc=None, init=True, repr=True, compare=False, kw_only=False, iter=True, serialize=False, metadata={})} -Y(x='Value of x', a='Not In __init__ signature', c=[], e='Value of e') + -Slots: {'x': None, 'a': None, 'b': None, 'c': None, 'd': None, 'e': None} +Slots: {'x': None, 'a': None, 'b': None, 'c': None, 'd': None, 'e': None, 'f': None} Source: def __eq__(self, other): @@ -20,14 +21,20 @@ def __eq__(self, other): and self.b == other.b ) if self.__class__ is other.__class__ else NotImplemented -def __init__(self, x, b=_b_default, c=None, *, e): +def __init__(self, x, b='Not In Repr', c=None, f=42, *, e): self.x = x - self.a = _a_default + self.a = 'Not In __init__ signature' self.b = b - self.c = _c_factory() if c is None else c - self.d = _d_default + self.c = c if c is not None else [] + self.d = 'Not Anywhere' self.e = e + self.f = f + +def __replace__(self, /, **changes): + new_kwargs = {'x': self.x, 'b': self.b, 'c': self.c, 'e': self.e, 'f': self.f} + new_kwargs |= changes + return self.__class__(**new_kwargs) def __repr__(self): - return f'{type(self).__qualname__}(x={self.x!r}, a={self.a!r}, c={self.c!r}, e={self.e!r})' + return f'' diff --git a/docs/extension_examples.md b/docs/extension_examples.md index 72ae79e..af23c62 100644 --- a/docs/extension_examples.md +++ b/docs/extension_examples.md @@ -82,13 +82,15 @@ their attribute is set. ## Gatherers ## ### What about using annotations instead of `Field(init=False, ...)` ### -This seems to be a feature people keep requesting for `dataclasses`. +This seems to be a feature people keep requesting for `dataclasses`. This implements it on top of `prefab`. To implement this you need to create a new annotated_gatherer function. -> Note: Field classes will be frozen when running under pytest. +> Note: Field class instances will be frozen when running under pytest. > They should not be mutated by gatherers. -> If you need to change the value of a field use Field.from_field(...) to make a new instance. +> If you need to change the value of a field use field_type.from_field(...) to make a new instance. ```{literalinclude} code_examples/docs_ex09_annotated.py ``` + +Note that this is unlikely ever to be a standard feature of `prefab` as I think this is worse. diff --git a/src/ducktools/classbuilder/__init__.py b/src/ducktools/classbuilder/__init__.py index 85aa2e5..c01361d 100644 --- a/src/ducktools/classbuilder/__init__.py +++ b/src/ducktools/classbuilder/__init__.py @@ -55,7 +55,7 @@ # Change this name if you make heavy modifications INTERNALS_DICT = "__classbuilder_internals__" -META_GATHERER_NAME = "_meta_gatherer" +META_GATHERER_NAME = "__classbuilder_meta_gatherer__" GATHERED_DATA = "__classbuilder_gathered_fields__" # If testing, make Field classes frozen to make sure attributes are not @@ -158,16 +158,16 @@ def print_generated_code(cls): print(textwrap.indent("\n".join(annotation_list), " ")) -def build_completed(ns): +def build_completed(cls): """ Utility function to determine if a class has completed the construction process. - :param ns: class namespace + :param cls: class to check :return: True if built, False otherwise """ try: - return ns[INTERNALS_DICT]["build_complete"] + return cls.__dict__[INTERNALS_DICT]["build_complete"] except KeyError: return False @@ -640,6 +640,36 @@ def hash_generator(cls, funcname="__hash__"): ) +def add_methods(cls, methods, *, internals=None): + """ + Unconditionally add methods to a class and update the internals dict + + :param methods: iterable of methods to add to a class + :param internals: the classbuilder_internals dict of the class + this is used directly by `builder` + :return: The complete current set of methods assigned to the class + """ + if internals is None: + try: + internals = cls.__dict__[INTERNALS_DICT] + except KeyError: + raise TypeError(f"{cls} is not a classbuilder generated class") + + existing_methods = internals.get("methods", {}) + new_methods = {} + + for method in methods: + setattr(cls, method.funcname, method) + new_methods[method.funcname] = method + + all_methods = _MappingProxyType(existing_methods | new_methods) + + # Update the internals dict + internals["methods"] = all_methods + + return all_methods + + def builder(cls=None, /, *, gatherer, methods, flags=None, fix_signature=True, field_getter=get_fields): """ The main builder for class generation @@ -711,10 +741,7 @@ def builder(cls=None, /, *, gatherer, methods, flags=None, fix_signature=True, f internals["fields"] = fields # Assign all of the method generators - internal_methods = {} - for method in methods: - setattr(cls, method.funcname, method) - internal_methods[method.funcname] = method + internal_methods = add_methods(cls, methods, internals=internals) if "__eq__" in internal_methods and "__hash__" not in internal_methods: # If an eq method has been defined and a hash method has not @@ -723,8 +750,6 @@ def builder(cls=None, /, *, gatherer, methods, flags=None, fix_signature=True, f if "__hash__" not in cls.__dict__: setattr(cls, "__hash__", None) - internals["methods"] = _MappingProxyType(internal_methods) - # Fix for inspect.signature(cls) if fix_signature: setattr(cls, "__signature__", signature_maker) @@ -1121,7 +1146,7 @@ def _build_field(): } modifications = {"__slots__": field_docs} - field_methods = {repr_maker, eq_maker} + field_methods = {repr_maker, eq_maker, replace_maker} if _UNDER_TESTING: field_methods |= {frozen_setattr_maker, frozen_delattr_maker} @@ -1359,7 +1384,8 @@ def field_unified_gatherer(cls_or_ns): if v_anno is NOTHING: use_annotations = False else: - if is_classvar(v_anno): + _t = resolve_type(v_anno) + if is_classvar(_t): k_type = type(v).__name__ raise TypeError(f"{k!r} is a ClassVar, but {k_type!r} instances are not supported as ClassVars") diff --git a/src/ducktools/classbuilder/__init__.pyi b/src/ducktools/classbuilder/__init__.pyi index 10ec1e2..462e967 100644 --- a/src/ducktools/classbuilder/__init__.pyi +++ b/src/ducktools/classbuilder/__init__.pyi @@ -5,7 +5,7 @@ import typing_extensions __lazy_modules__: list[str] -from collections.abc import Callable +from collections.abc import Callable, Iterable from types import MappingProxyType if sys.version_info >= (3, 14): @@ -42,7 +42,7 @@ def get_generated_code(cls: type) -> dict[str, GeneratedCode]: ... def print_generated_code(cls: type) -> None: ... -def build_completed(ns: _CopiableMappings) -> bool: ... +def build_completed(cls: type) -> bool: ... def _get_inst_fields(inst: typing.Any) -> dict[str, typing.Any]: ... @@ -142,6 +142,13 @@ default_methods: frozenset[MethodMaker] _TypeT = typing.TypeVar("_TypeT", bound=type) +def add_methods( + cls: type, + methods: Iterable[MethodMaker], + *, + internals: None | dict[str, typing.Any] = ... +) -> dict[str, MethodMaker]: ... + @typing.overload def builder( cls: _TypeT, @@ -184,6 +191,8 @@ class SlotMakerMeta(type): ) -> _TypeT: ... +# Only technically frozen under testing but we should *act* like they are frozen +@typing.dataclass_transform(field_specifiers=(Field,), frozen_default=True) class Field(metaclass=SlotMakerMeta): default: _NothingType | typing.Any default_factory: _NothingType | typing.Any @@ -214,6 +223,7 @@ class Field(metaclass=SlotMakerMeta): def __init_subclass__(cls, frozen: bool = ..., ignore_annotations: bool = ...): ... def __repr__(self) -> str: ... def __eq__(self, other: Field | object) -> bool: ... + def __replace__(self, **kwargs) -> typing.Self: ... def validate_field(self) -> None: ... @classmethod def from_field(cls, fld: Field, /, **kwargs: typing.Any) -> typing.Self: ... diff --git a/src/ducktools/classbuilder/annotations/annotations_314.py b/src/ducktools/classbuilder/annotations/annotations_314.py index fd83a4a..260ed44 100644 --- a/src/ducktools/classbuilder/annotations/annotations_314.py +++ b/src/ducktools/classbuilder/annotations/annotations_314.py @@ -35,6 +35,7 @@ import sys +# fmt: off if sys.version_info >= (3, 15): # cover-req-ge3.15 import annotationlib as _annotationlib import reannotate as _reannotate @@ -55,6 +56,7 @@ def __getattr__(self, item): _annotationlib = _LazyAnnotationLib() _reannotate = _LazyReannotate() +# fmt: on def _get_annotate_from_class_namespace(ns): @@ -103,10 +105,7 @@ def get_ns_annotations(ns, cls=None): try: annotations = annotate(1) # Format.VALUE is 1 except Exception: - annotations = _reannotate.call_annotate_deferred( - annotate, - owner=cls - ) + annotations = _reannotate.call_annotate_deferred(annotate, owner=cls) if annotations is None: annotations = {} @@ -123,7 +122,11 @@ def resolve_type(obj, stringify_forwardrefs=False): evaluation fails, defaults to False :return: Evaluated reference """ - if "reannotate" in sys.modules and isinstance(obj, _reannotate.DeferredAnnotation): + # fmt: off + if ( + "reannotate" in sys.modules + and isinstance(obj, (_reannotate.DeferredAnnotation, _annotationlib.ForwardRef)) + ): if stringify_forwardrefs: try: return obj.evaluate(format=_annotationlib.Format.VALUE) @@ -131,6 +134,7 @@ def resolve_type(obj, stringify_forwardrefs=False): return obj.evaluate(format=_annotationlib.Format.STRING) else: return obj.evaluate(format=_annotationlib.Format.FORWARDREF) + # fmt: on return obj diff --git a/src/ducktools/classbuilder/prefab.py b/src/ducktools/classbuilder/prefab.py index 37d3527..ed00418 100644 --- a/src/ducktools/classbuilder/prefab.py +++ b/src/ducktools/classbuilder/prefab.py @@ -491,6 +491,7 @@ def _prefab_preprocess( replace, dict_method, recursive_repr, + gatherer, gathered_fields, ignore_annotations, ): @@ -499,7 +500,7 @@ def _prefab_preprocess( cls_dict = cls.__dict__ - if build_completed(cls_dict): + if build_completed(cls): raise PrefabError( f"Decorated class {cls.__name__!r} " f"has already been processed as a Prefab." @@ -519,9 +520,7 @@ def _prefab_preprocess( slots = cls_dict.get("__slots__") slotted = False if slots is None else True - if gathered_fields is None: - gatherer = prefab_gatherer - else: + if gathered_fields is not None: gatherer = gathered_fields # Decide which methods need to be added to the class based on presence @@ -584,7 +583,7 @@ def _prefab_preprocess( return gatherer, methods, flags -def _prefab_post_process(cls, /, *, fields, kw_only): +def _prefab_postprocess(cls, /, *, fields, kw_only): # Processor to do post-construction checks # Error check: Check that the arguments to pre/post init are valid fields for func_name in (PRE_INIT_FUNC, POST_INIT_FUNC): @@ -668,6 +667,7 @@ def _make_prefab( replace=True, dict_method=False, recursive_repr=False, + gatherer=prefab_gatherer, gathered_fields=None, ignore_annotations=False, ): @@ -687,11 +687,13 @@ def _make_prefab( :param replace: Add a generated __replace__ method :param dict_method: Include an as_dict method for faster dictionary creation :param recursive_repr: Safely handle repr in case of recursion + :param gatherer: A gatherer to use for collecting `Attribute`s :param gathered_fields: Pre-gathered fields callable, to skip re-collecting attributes :param ignore_annotations: Ignore annotated fields and only look at `attribute` fields :return: class with __ methods defined """ # Preprocess to obtain settings + # gatherer will be appropriately replaced if gathered_fields is defined gatherer, methods, flags = _prefab_preprocess( cls, init=init, @@ -705,6 +707,7 @@ def _make_prefab( replace=replace, dict_method=dict_method, recursive_repr=recursive_repr, + gatherer=gatherer, gathered_fields=gathered_fields, ignore_annotations=ignore_annotations, ) @@ -729,7 +732,7 @@ def _make_prefab( ) # Post construction checks - _prefab_post_process(cls, kw_only=kw_only, fields=fields) + _prefab_postprocess(cls, kw_only=kw_only, fields=fields) return cls @@ -760,6 +763,7 @@ def __init_subclass__( :param ignore_annotations: Ignore type annotations when gathering fields, only look for slots or attribute(...) values :param slots: automatically generate slots for this class's attributes + :param gatherer: A gatherer to use for collecting `Attribute`s """ default_values = { "init": True, @@ -819,6 +823,7 @@ def prefab( replace=True, dict_method=False, recursive_repr=False, + gatherer=prefab_gatherer, ignore_annotations=False, ): """ @@ -858,6 +863,7 @@ def prefab( replace=replace, dict_method=dict_method, recursive_repr=recursive_repr, + gatherer=gatherer, ignore_annotations=ignore_annotations, ) else: @@ -874,6 +880,7 @@ def prefab( replace=replace, dict_method=dict_method, recursive_repr=recursive_repr, + gatherer=gatherer, ignore_annotations=ignore_annotations, ) diff --git a/src/ducktools/classbuilder/prefab.pyi b/src/ducktools/classbuilder/prefab.pyi index 44faaca..596d437 100644 --- a/src/ducktools/classbuilder/prefab.pyi +++ b/src/ducktools/classbuilder/prefab.pyi @@ -10,6 +10,7 @@ from collections.abc import Callable # type: ignore from . import ( NOTHING, Field, + GathererProtocol, GeneratedCode, MethodMaker, SlotMakerMeta, @@ -57,6 +58,7 @@ class Attribute(Field): __slots__: dict __signature__: _SignatureMaker __classbuilder_gathered_fields__: tuple[dict[str, Field], dict[str, typing.Any]] + __classbuilder_meta_gatherer__: GathererProtocol iter: bool serialize: bool @@ -152,6 +154,7 @@ def _make_prefab( replace: bool = ..., dict_method: bool = ..., recursive_repr: bool = ..., + gatherer: GathererProtocol[Attribute] = ..., gathered_fields: Callable[[type], tuple[dict[str, Attribute], dict[str, typing.Any]]] | None = ..., ignore_annotations: bool = ..., ) -> type: ... @@ -160,7 +163,7 @@ def _make_prefab( @dataclass_transform(field_specifiers=(Attribute, attribute)) class Prefab(metaclass=SlotMakerMeta): __classbuilder_internals__: dict[str, typing.Any] - _meta_gatherer: Callable[[type | _CopiableMappings], tuple[dict[str, Field], dict[str, typing.Any]]] = ... + __classbuilder_meta_gatherer__: GathererProtocol __slots__: dict[str, typing.Any] = ... def __init_subclass__( cls, @@ -176,6 +179,7 @@ class Prefab(metaclass=SlotMakerMeta): replace: bool = ..., dict_method: bool = ..., recursive_repr: bool = ..., + gatherer: GathererProtocol[Attribute] = ..., ) -> None: ... # As far as I can tell these are the correct types @@ -229,6 +233,7 @@ def prefab( replace: bool = ..., dict_method: bool = ..., recursive_repr: bool = ..., + gatherer: GathererProtocol[Attribute] = ..., ignore_annotations: bool = ..., ) -> typing.Any: ... diff --git a/tests/prefab/test_alternate_gatherer.py b/tests/prefab/test_alternate_gatherer.py new file mode 100644 index 0000000..abcc535 --- /dev/null +++ b/tests/prefab/test_alternate_gatherer.py @@ -0,0 +1,65 @@ +from ducktools.classbuilder import META_GATHERER_NAME, SlotFields, make_field_gatherer, make_slot_gatherer, get_flags +from ducktools.classbuilder.prefab import attribute, Attribute, Prefab, prefab, get_attributes + + +slot_gatherer = make_slot_gatherer(field_type=Attribute) +attrib_gatherer = make_field_gatherer(field_type=Attribute, leave_default_values=False) + +class TestUsesGatherer: + def test_decorator(self): + @prefab(gatherer=slot_gatherer) + class SlotGathered: + __slots__ = SlotFields(a=42) + + assert SlotGathered().a == 42 + assert get_attributes(SlotGathered) == {'a': Attribute(default=42)} + + + def test_baseclass(self): + class SlotGathered(Prefab, gatherer=slot_gatherer): + __slots__ = SlotFields(a=42) + + assert SlotGathered().a == 42 + assert get_attributes(SlotGathered) == {'a': Attribute(default=42)} + + +class TestIgnoresAnnotations: + def test_decorator(self): + @prefab(gatherer=attrib_gatherer) + class AnnotationsNotGathered: + a: int + b: str + c: int = attribute(default=42) + + ex = AnnotationsNotGathered() + assert not hasattr(ex, 'a') + assert not hasattr(ex, 'b') + assert ex.c == 42 + + assert get_attributes(AnnotationsNotGathered) == {'c': attribute(default=42)} + + def test_baseclass(self): + class AnnotationsNotGathered(Prefab, gatherer=attrib_gatherer): + a: int + b: str + c: int = attribute(default=42) + + ex = AnnotationsNotGathered() + assert not hasattr(ex, 'a') + assert not hasattr(ex, 'b') + assert ex.c == 42 + + assert get_attributes(AnnotationsNotGathered) == {'c': attribute(default=42)} + + # Base class examples keep the meta gatherer + assert getattr(AnnotationsNotGathered, META_GATHERER_NAME) == attrib_gatherer + + # Check the meta gatherer is preserved in a subclass + class AnnotationsNotGatheredSub(AnnotationsNotGathered): + d: int + e: str + f: int = attribute(default=314) + + assert getattr(AnnotationsNotGatheredSub, META_GATHERER_NAME) == attrib_gatherer + + assert get_attributes(AnnotationsNotGatheredSub) == {'c': attribute(default=42), 'f': attribute(default=314)} diff --git a/tests/py314_tests/test_annotation_extras.py b/tests/py314_tests/test_annotation_extras.py index 1538670..1128280 100644 --- a/tests/py314_tests/test_annotation_extras.py +++ b/tests/py314_tests/test_annotation_extras.py @@ -1,5 +1,6 @@ +from ducktools.classbuilder import Field from ducktools.classbuilder.annotations import replace_generic_with_arg, get_func_annotations, resolve_type -from ducktools.classbuilder.prefab import InitParam +from ducktools.classbuilder.prefab import InitParam, Attribute from annotationlib import ForwardRef from typing import Annotated @@ -149,4 +150,18 @@ def f(a: int, b: undefined): ... assert resolve_type(annos['a'], stringify_forwardrefs=True) is int b_fr = resolve_type(annos['b'], stringify_forwardrefs=True) - assert b_fr == "undefined" \ No newline at end of file + assert b_fr == "undefined" + + def test_auto_resolve_type(self): + # test type is resolved on Field and Attribute instances + ref = ForwardRef("int") + def_anno = DeferredAnnotation(str) + + fld_ref = Field(type=ref) + fld_def = Field(type=def_anno) + + attrib_ref = Attribute(type=ref) + attrib_def = Attribute(type=def_anno) + + assert fld_ref.type == attrib_ref.type == int + assert fld_def.type == attrib_def.type == str diff --git a/tests/test_core.py b/tests/test_core.py index f58eafc..42917e5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,6 +6,7 @@ INTERNALS_DICT, NOTHING, + add_methods, builder, default_methods, eq_maker, @@ -80,6 +81,38 @@ def __init__(self): assert ValueX.__dict__["demo"] != method_desc +@graalpy_fails +def test_add_methods(): + # This is to test add_methods with internals=None + # add_methods with internals specified is tested + # by the standard builder + @slotclass + class Example: + __slots__ = SlotFields(x=42) + + def generator(cls, funcname="demo"): + code = f"def {funcname}(self): return self.x" + globs = {} + return GeneratedCode(code, globs) + + method_desc = MethodMaker("demo", generator) + + add_methods(Example, [method_desc]) + + assert get_methods(Example)["demo"] == method_desc + + ex = Example() + assert ex.demo() == 42 + + +def test_add_methods_fails(): + # Test that add_methods fails on non built classes + class Example: + pass + + with pytest.raises(TypeError): + add_methods(Example, [init_maker]) + def test_construct_field(): f = Field() assert f.default is NOTHING