diff --git a/README.md b/README.md index b85a22b..3b218d4 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,12 @@ Install from PyPI with: In order to create a class decorator using `ducktools.classbuilder` there are a few things you need to prepare. -1. A field gathering function to analyse the class and collect valid `Field`s. +1. A field gathering function to analyse the class and collect valid `Field`s and provide + any modifications that need to be applied to the class attributes. * An example `slot_gatherer` is included. 2. Code generators that can make use of the gathered `Field`s to create magic method - source code. - * Example `init_maker`, `repr_maker` and `eq_maker` generators are included. + source code. To be made into descriptors by `MethodMaker`. + * Example `init_generator`, `repr_generator` and `eq_generator` generators are included. 3. A function that calls the `builder` function to apply both of these steps. A field gathering function needs to take the original class as an argument and @@ -42,25 +43,26 @@ class[^1] where keyword arguments define the names and values for the fields. Code generator functions need to be converted to descriptors before being used. This is done using the provided `MethodMaker` descriptor class. -ex: `init_desc = MethodMaker("__init__", init_maker)` +ex: `init_maker = MethodMaker("__init__", init_generator)`. These parts can then be used to make a basic class boilerplate generator by providing them to the `builder` function. ```python from ducktools.classbuilder import ( - builder, - slot_gatherer, - init_maker, eq_maker, repr_maker, + builder, + slot_gatherer, + init_generator, eq_generator, repr_generator, MethodMaker, ) -init_desc = MethodMaker("__init__", init_maker) -repr_desc = MethodMaker("__repr__", repr_maker) -eq_desc = MethodMaker("__eq__", eq_maker) +init_maker = MethodMaker("__init__", init_generator) +repr_maker = MethodMaker("__repr__", repr_generator) +eq_maker = MethodMaker("__eq__", eq_generator) + def slotclass(cls): - return builder(cls, gatherer=slot_gatherer, methods={init_desc, repr_desc, eq_desc}) + return builder(cls, gatherer=slot_gatherer, methods={init_maker, repr_maker, eq_maker}) ``` ## Slot Class Usage ## @@ -189,9 +191,8 @@ It will copy values provided as the `type` to `Field` into the Values provided to `doc` will be placed in the final `__slots__` field so they are present on the class if `help(...)` is called. -A fairly basic `annotations_gatherer` and `annotationclass` are included -in `extras.py` which can be used to generate classbuilders that rely on -annotations. +A fairly basic `annotations_gatherer` and `annotationclass` are also included +and can be used to generate classbuilders that rely on annotations. If you want something with more features you can look at the `prefab.py` implementation which provides a 'prebuilt' implementation. diff --git a/docs/api.md b/docs/api.md index c7200cf..37ba0b5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,6 +14,11 @@ .. autofunction:: ducktools.classbuilder::slotclass ``` +```{eval-rst} +.. autofunction:: ducktools.classbuilder::annotationclass +``` + + ## Builder functions and classes ## ```{eval-rst} @@ -33,7 +38,11 @@ ``` ```{eval-rst} -.. autofunction:: ducktools.classbuilder::slot_gatherer +.. autofunction:: ducktools.classbuilder::make_slot_gatherer +``` + +```{eval-rst} +.. autofunction:: ducktools.classbuilder::make_annotation_gatherer ``` ```{eval-rst} diff --git a/docs/extension_examples.md b/docs/extension_examples.md index a506602..035c690 100644 --- a/docs/extension_examples.md +++ b/docs/extension_examples.md @@ -21,50 +21,15 @@ to be customisable. Assignment of method builders is where all of the functions that will lazily create `__init__` and other magic methods are added to the class. -## Structure ## +## Creating a generator ## -### The Builder Function ### - -```{eval-rst} -.. autofunction:: ducktools.classbuilder::builder - :noindex: -``` - -This function is the core class generator which takes your decorated class and -analyses and collects valid fields and then attaches the method makers. - -The field information is stored in the `INTERNALS_DICT` attribute which should generally -not need to be accessed directly. `get_fields` and `get_flags` functions are to be -used to access the important keys. - -`get_fields(cls)` will return the resolved information obtained from this class and subclasses. - -`get_fields(cls, local=True)` will return the field information obtained from **this class only**. - -Now let's look at what the keyword arguments to `builder` need to be. - -#### Flags #### - -Flags are information that defines how the entire class should be generated, for use by -method generators when operating on the class. - -The default makers in `ducktools.classbuilder` make use of one flag - `"kw_only"` - -which indicates that a class `__init__` function should only take keyword arguments. - -Prefabs also make use of a `"slotted"` flag to indicate if the class has been generated -with `__slots__` (checking for the existence of `__slots__` could find that a user has -manually placed slots in the class). - -Flags are set using a dictionary with these keys and boolean values, for example: - -`cls = builder(cls, gatherer=..., methods=..., flags={"kw_only": True, "slotted": True})` - -#### Gatherers #### +### Gatherers ### This covers the *'gather the fields'* step of the process. -A `gatherer` in this case is a function which takes in the class and returns a dict -of `{"field_name": Field(...)}` values based on some analysis of your class. +A `gatherer` in this case is a function which takes in the class and returns both a dict +of `{"field_name": Field(...)}` values based on some analysis of your class and a second +dictionary of attributes to modify on the main class. An example gatherer is given in `slot_gatherer` which will take the keys and values from a dict subclass `SlotFields` and use that to prepare the field information for @@ -91,63 +56,186 @@ class GatherExample: y=9, z=Field( default=42, - doc="I always knew there was something fundamentally wrong with the universe." + doc="I always knew there was something fundamentally wrong with the universe.", + type=int, ) ) pprint(slot_gatherer(GatherExample)) ``` -#### Methods #### +Output: +``` +({'x': Field(default=6, default_factory=, type=, doc=None), + 'y': Field(default=9, default_factory=, type=, doc=None), + 'z': Field(default=42, default_factory=, type=, doc='I always knew there was something fundamentally wrong with the universe.')}, + {'__annotations__': {'z': }, + '__slots__': {'x': None, + 'y': None, + 'z': 'I always knew there was something fundamentally wrong ' + 'with the universe.'}}) +``` + +The first dictionary shows the field names and the information that will be used by +the code generators attached to the class to create any required magic methods. + +The second dictionary shows which values on the original class are going to be replaced. +Replacing the value of `__slots__` at this point wont change the actual internal slots +but will provide the strings given as additional documentation to `help(GatherExample)`. + +Here's a similar example using the `annotations_gatherer` + +```python +from pprint import pprint +from ducktools.classbuilder import annotation_gatherer, Field + + +class GatherExample: + x: int + y: list[str] = Field(default_factory=list) + z: int = Field(default=42, doc="Unused in non slot classes.") + + +pprint(annotation_gatherer(GatherExample)) +``` + +Output: +``` +({'x': Field(default=, default_factory=, type=, doc=None), + 'y': Field(default=, default_factory=, type=list[str], doc=None), + 'z': Field(default=42, default_factory=, type=, doc='Unused in non slot classes.')}, + {'y': , 'z': 42}) +``` + +Here we can see that the type values have been filled in based on the annotations provided. +The value of 'z' on the class is being replaced by the default value and the value of 'y' +appears to be set to a ``. The use of `NOTHING` here is actually an indicator +to the builder to remove this attribute from the class. + +> Gatherer functions **should not** modify the class directly. +> All class modification should occur in the `builder` function. + +### Methods ### `methods` needs to be a set of `MethodMaker` instances which are descriptors that replace themselves with the required methods on first access. A `MethodMaker` takes two arguments: -`funcname` - the name of the method to attach -`code_generator` - a code generator function that returns a tuple of source code and globals dict. +`funcname` - the name of the method to attach - such as `__init__` or `__repr__` +`code_generator` - a code generator function. ```{eval-rst} .. autoclass:: ducktools.classbuilder::MethodMaker :noindex: ``` -An example of these descriptors is the `init_desc` method maker that generates the `__init__` -function. Their behaviour is best observed by looking at the class after it is generated. +The `code_generator` function to be provided needs to take the prepared class as the only argument +and return a tuple of source code and a globals dictionary in which to execute the code. +These can be examined by looking at the output of any of the `_generator` functions. +For example the included `init_generator`. ```python -from ducktools.classbuilder import slotclass, SlotFields, Field, init_desc +from ducktools.classbuilder import annotationclass, init_generator + +@annotationclass +class InitExample: + a: str + b: str = "b" + obj: object = object() + +output = init_generator(InitExample) +print(output[0]) +print(output[1]) +``` + +Output: +``` +def __init__(self, a, b=_b_default, obj=_obj_default): + self.a = a + self.b = b + self.obj = obj + +{'_b_default': 'b', '_obj_default': } +``` + +> Note: The values are replaced by `_name_default` for defaults in the parameters +> in order to make sure that the defaults are the exact objects provided at generation. + +To convert these into the actual functions these generators are provided to a +`MethodMaker` descriptor class. The `funcname` provided must match the name of +the function in the generated code and will also be the attribute to which the +descriptor is attached. `init_maker = MethodMaker('__init__', init_generator)` +in this case. + +The `MethodMaker` descriptors actions can be observed by looking at the class +dictionary before and after `__init__` is first called. + +```python +from ducktools.classbuilder import annotationclass + + +@annotationclass +class InitExample: + a: str + b: str = "b" -@slotclass -class GatherExample: - __slots__ = SlotFields( - x=6, - y=9, - z=Field( - default=42, - doc="I always knew there was something fundamentally wrong with the universe." - ) - ) # Access through the __dict__ to avoid code generation -print(f'Before generation: {GatherExample.__dict__["__init__"] = }') +print(f'Before generation: {InitExample.__dict__["__init__"] = }') # Now generate the code by forcing python to call __init__ -ex = GatherExample() +ex = InitExample("a") -print(f'After generation: {GatherExample.__dict__["__init__"] = }') +print(f'After generation: {InitExample.__dict__["__init__"] = }') +``` + +Output: +``` +Before generation: InitExample.__dict__["__init__"] = +After generation: InitExample.__dict__["__init__"] = +``` + +### Flags ### + +Flags are information that defines how the entire class should be generated, for use by +method generators when operating on the class. + +The default makers in `ducktools.classbuilder` make use of one flag - `"kw_only"` - +which indicates that a class `__init__` function should only take keyword arguments. -# Look at the contents of the maker -print("\nDescriptor Contents: ") -print(f"{init_desc.funcname = }") -print(f"{init_desc.code_generator = }\n") +Prefabs also make use of a `"slotted"` flag to indicate if the class has been generated +with `__slots__` (checking for the existence of `__slots__` could find that a user has +manually placed slots in the class). + +Flags are set using a dictionary with these keys and boolean values, for example: + +`cls = builder(cls, gatherer=..., methods=..., flags={"kw_only": True, "slotted": True})` -# Look at the output of the code generator -generated = init_desc.code_generator(GatherExample) -print(f"Globals: {generated[1]!r}") -print(f"Source:\n{generated[0]}") +### The Builder Function ### + +```{eval-rst} +.. autofunction:: ducktools.classbuilder::builder + :noindex: ``` +Once all these pieces are in place they can be provided to the `builder` function. + +This uses the provided `gatherer` to get the field information and attribute changes +that need to be made to the class. + +When applying the attribute changes, any attribute values which are given as `NOTHING` are +deleted. +Afterwards it looks through parent classes and gathers a full set of inherited fields. + +An internals dictionary is generated which contains the full inherited fields as `fields`, +the fields from the decorated class only as `local_fields` and any flags passed through +as `flags`. This is then stored in a `__classbuilder_internals__` attribute. These can be +accessed using the `get_fields` and `get_flags` functions provided. + +`get_fields(cls)` will return the resolved information obtained from this class and subclasses. + +`get_fields(cls, local=True)` will return the field information obtained from **this class only**. + ### Extending `Field` ### When customising generator methods (or adding new ones) it may be useful to @@ -198,48 +286,47 @@ These methods can be reused to make `slotclasses` 'frozen'. ```python from ducktools.classbuilder import ( - slotclass, - SlotFields, - default_methods, - frozen_setattr_desc, - frozen_delattr_desc, + slotclass, + SlotFields, + default_methods, + frozen_setattr_maker, + frozen_delattr_maker, ) - -new_methods = default_methods | {frozen_setattr_desc, frozen_delattr_desc} +new_methods = default_methods | {frozen_setattr_maker, frozen_delattr_maker} def frozen(cls, /): - return slotclass(cls, methods=new_methods) + return slotclass(cls, methods=new_methods) if __name__ == "__main__": - @frozen - class FrozenEx: - __slots__ = SlotFields( - x=6, - y=9, - product=42, - ) - - - ex = FrozenEx() - print(ex) - - try: - ex.y = 7 - except TypeError as e: - print(e) - - try: - ex.z = "new value" - except TypeError as e: - print(e) - - try: - del ex.y - except TypeError as e: - print(e) + @frozen + class FrozenEx: + __slots__ = SlotFields( + x=6, + y=9, + product=42, + ) + + + ex = FrozenEx() + print(ex) + + try: + ex.y = 7 + except TypeError as e: + print(e) + + try: + ex.z = "new value" + except TypeError as e: + print(e) + + try: + del ex.y + except TypeError as e: + print(e) ``` #### Iterable Classes #### @@ -248,23 +335,24 @@ Say you want to make the class iterable, so you want to add `__iter__`. ```python from ducktools.classbuilder import ( - default_methods, get_fields, slotclass, MethodMaker, SlotFields + default_methods, + get_fields, + slotclass, + MethodMaker, + SlotFields, ) -def iter_maker(cls): +def iter_generator(cls): field_names = get_fields(cls).keys() field_yield = "\n".join(f" yield self.{f}" for f in field_names) - code = ( - f"def __iter__(self):\n" - f"{field_yield}" - ) + code = f"def __iter__(self):\n" f"{field_yield}" globs = {} return code, globs -iter_desc = MethodMaker("__iter__", iter_maker) -new_methods = frozenset(default_methods | {iter_desc}) +iter_maker = MethodMaker("__iter__", iter_generator) +new_methods = frozenset(default_methods | {iter_maker}) def iterclass(cls=None, /): @@ -282,7 +370,6 @@ if __name__ == "__main__": e=5, ) - ex = IterDemo() print([item for item in ex]) ``` @@ -309,10 +396,10 @@ Here is an example of adding the ability to exclude fields from `__repr__`. ```python from ducktools.classbuilder import ( - eq_desc, + eq_maker, fieldclass, get_fields, - init_desc, + init_maker, slotclass, Field, SlotFields, @@ -325,7 +412,7 @@ class FieldExt(Field): __slots__ = SlotFields(repr=True) -def repr_exclude_maker(cls): +def repr_exclude_generator(cls): fields = get_fields(cls) # Use getattr with default True for the condition so @@ -343,12 +430,12 @@ def repr_exclude_maker(cls): return code, globs -repr_desc = MethodMaker("__repr__", repr_exclude_maker) +repr_exclude_maker = MethodMaker("__repr__", repr_exclude_generator) if __name__ == "__main__": - methods = frozenset({init_desc, eq_desc, repr_desc}) + methods = frozenset({init_maker, eq_maker, repr_exclude_maker}) @slotclass(methods=methods) class Example: @@ -380,7 +467,7 @@ errors when the `__init__` method is generated. ```python from ducktools.classbuilder import ( builder, - eq_desc, + eq_maker, fieldclass, get_fields, slot_gatherer, @@ -396,7 +483,7 @@ class PosOnlyField(Field): __slots__ = SlotFields(pos_only=True) -def init_maker(cls): +def init_generator(cls): fields = get_fields(cls) arglist = [] @@ -434,7 +521,7 @@ def init_maker(cls): return code, globs -def repr_maker(cls): +def repr_generator(cls): fields = get_fields(cls) content_list = [] for name, field in fields.items(): @@ -453,9 +540,9 @@ def repr_maker(cls): return code, globs -init_desc = MethodMaker("__init__", init_maker) -repr_desc = MethodMaker("__repr__", repr_maker) -new_methods = frozenset({init_desc, repr_desc, eq_desc}) +init_maker = MethodMaker("__init__", init_generator) +repr_maker = MethodMaker("__repr__", repr_generator) +new_methods = frozenset({init_maker, repr_maker, eq_maker}) def pos_slotclass(cls, /): @@ -535,7 +622,7 @@ class ConverterField(Field): __slots__ = SlotFields(converter=None) -def setattr_maker(cls): +def setattr_generator(cls): fields = get_fields(cls) converters = {} for k, v in fields.items(): @@ -558,8 +645,8 @@ def setattr_maker(cls): return code, globs -setattr_desc = MethodMaker("__setattr__", setattr_maker) -methods = frozenset(default_methods | {setattr_desc}) +setattr_maker = MethodMaker("__setattr__", setattr_generator) +methods = frozenset(default_methods | {setattr_maker}) def converterclass(cls, /): @@ -576,6 +663,7 @@ if __name__ == "__main__": ex = ConverterEx("42", "42") print(ex) + ``` ### Gatherers ### @@ -584,6 +672,8 @@ if __name__ == "__main__": This seems to be a feature people keep requesting for `dataclasses`. This is also doable. +This is a long example but is designed to show how you can use these tools to implement such a thing. + > Note: Field classes 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. @@ -605,8 +695,7 @@ from ducktools.classbuilder import ( ) -# New equivalent to dataclasses "Field", these still need to be created -# in order to generate the magic methods correctly. +# First we need a new field that can store these modifications @fieldclass class AnnoField(Field): __slots__ = SlotFields( @@ -617,9 +706,12 @@ class AnnoField(Field): ) -# Modifying objects +# Our 'Annotated' tools need to be combinable and need to contain the keyword argument +# and value they are intended to change. +# To this end we make a FieldModifier class that stores the keyword values given in a +# dictionary as 'modifiers'. This makes it easy to merge modifiers later. class FieldModifier: - __slots__ = ("modifiers", ) + __slots__ = ("modifiers",) modifiers: dict[str, Any] def __init__(self, **modifiers): @@ -637,6 +729,8 @@ class FieldModifier: return NotImplemented +# Here we make the modifiers and give them the arguments to Field we +# wish to change with their usage. KW_ONLY = FieldModifier(kw_only=True) NO_INIT = FieldModifier(init=False) NO_REPR = FieldModifier(repr=False) @@ -644,43 +738,49 @@ NO_COMPARE = FieldModifier(compare=False) IGNORE_ALL = FieldModifier(init=False, repr=False, compare=False) -def annotated_gatherer(cls: type) -> dict[str, Any]: +# Analyse the class and create these new Fields based on the annotations +def annotated_gatherer(cls: type) -> tuple[dict[str, AnnoField], dict[str, Any]]: # String annotations *MUST* be evaluated for this to work - # dataclasses currently does not require this + # Trying to parse the Annotations as strings would add a *lot* of extra work cls_annotations = inspect.get_annotations(cls, eval_str=True) cls_fields = {} + # This gatherer doesn't make any class modifications but still needs + # To have a dict as a return value + cls_modifications = {} + for key, anno in cls_annotations.items(): modifiers = {} - typ = NOTHING if get_origin(anno) is Annotated: - typ = anno.__args__[0] meta = anno.__metadata__ for v in meta: if isinstance(v, FieldModifier): + # Merge the modifier arguments to pass to AnnoField modifiers.update(v.modifiers) - elif not (anno is ClassVar or get_origin(anno) is ClassVar): - typ = anno + # Extract the actual annotation from the first argument + anno = anno.__origin__ - if typ is not NOTHING: - if key in cls.__dict__ and "__slots__" not in cls.__dict__: - val = cls.__dict__[key] - if isinstance(val, Field): - fld = AnnoField.from_field(val, type=typ, **modifiers) - else: - fld = AnnoField(default=val, type=typ, **modifiers) - else: - fld = AnnoField(type=typ, **modifiers) + if anno is ClassVar or get_origin(anno) is ClassVar: + continue - cls_fields[key] = fld + if key in cls.__dict__ and "__slots__" not in cls.__dict__: + val = cls.__dict__[key] + if isinstance(val, Field): + # Make a new field - DO NOT MODIFY FIELDS IN PLACE + fld = AnnoField.from_field(val, type=anno, **modifiers) + else: + fld = AnnoField(default=val, type=anno, **modifiers) + else: + fld = AnnoField(type=anno, **modifiers) - return cls_fields + cls_fields[key] = fld + return cls_fields, cls_modifications -def init_maker(cls): +def init_generator(cls): fields = get_fields(cls) flags = get_flags(cls) @@ -734,7 +834,7 @@ def init_maker(cls): return code, globs -def repr_maker(cls): +def repr_generator(cls): fields = get_fields(cls) content = ", ".join( f"{name}={{self.{name}!r}}" @@ -749,7 +849,7 @@ def repr_maker(cls): return code, globs -def eq_maker(cls): +def eq_generator(cls): class_comparison = "self.__class__ is other.__class__" field_names = [ name @@ -773,11 +873,11 @@ def eq_maker(cls): return code, globs -init_method = MethodMaker("__init__", init_maker) -repr_method = MethodMaker("__repr__", repr_maker) -eq_method = MethodMaker("__eq__", eq_maker) +init_maker = MethodMaker("__init__", init_generator) +repr_maker = MethodMaker("__repr__", repr_generator) +eq_maker = MethodMaker("__eq__", eq_generator) -methods = {init_method, repr_method, eq_method} +methods = {init_maker, repr_maker, eq_maker} def annotationsclass(cls=None, *, kw_only=False): @@ -795,7 +895,8 @@ def annotationsclass(cls=None, *, kw_only=False): @annotationsclass class X: x: str - y: ClassVar[str] = "This is okay" + y: ClassVar[str] = "This should be ignored" + z: Annotated[ClassVar[str], "Should be ignored"] = "This should also be ignored" a: Annotated[int, NO_INIT] = "Not In __init__ signature" b: Annotated[str, NO_REPR] = "Not In Repr" c: Annotated[list[str], NO_COMPARE] = AnnoField(default_factory=list) @@ -809,7 +910,7 @@ print(ex, "\n") pp(get_fields(X)) print("\nSource:") -print(init_maker(X)[0]) -print(eq_maker(X)[0]) -print(repr_maker(X)[0]) +print(init_generator(X)[0]) +print(eq_generator(X)[0]) +print(repr_generator(X)[0]) ``` diff --git a/docs_code/docs_ex1_basic.py b/docs_code/docs_ex1_basic.py index a66c54a..533e320 100644 --- a/docs_code/docs_ex1_basic.py +++ b/docs_code/docs_ex1_basic.py @@ -1,4 +1,4 @@ -from ducktools.classbuilder import slotclass, Field, SlotFields, get_fields +from ducktools.classbuilder import slotclass, Field, SlotFields @slotclass diff --git a/docs_code/docs_ex3_iterable.py b/docs_code/docs_ex3_iterable.py index b1097f4..73452fc 100644 --- a/docs_code/docs_ex3_iterable.py +++ b/docs_code/docs_ex3_iterable.py @@ -1,21 +1,29 @@ from ducktools.classbuilder import ( - default_methods, get_fields, slotclass, MethodMaker, SlotFields + default_methods, + get_fields, + slotclass, + MethodMaker, + SlotFields, ) -def iter_maker(cls): +def iter_generator(cls): field_names = get_fields(cls).keys() - field_yield = "\n".join(f" yield self.{f}" for f in field_names) + if field_names: + field_yield = "\n".join(f" yield self.{f}" for f in field_names) + else: + field_yield = " yield from ()" + code = ( f"def __iter__(self):\n" - f"{field_yield}" + f"{field_yield}\n" ) globs = {} return code, globs -iter_desc = MethodMaker("__iter__", iter_maker) -new_methods = frozenset(default_methods | {iter_desc}) +iter_maker = MethodMaker("__iter__", iter_generator) +new_methods = frozenset(default_methods | {iter_maker}) def iterclass(cls=None, /): @@ -33,6 +41,5 @@ class IterDemo: e=5, ) - ex = IterDemo() print([item for item in ex]) diff --git a/docs_code/docs_ex4_exclude_repr.py b/docs_code/docs_ex4_exclude_repr.py index 3c4a0e8..7ee4513 100644 --- a/docs_code/docs_ex4_exclude_repr.py +++ b/docs_code/docs_ex4_exclude_repr.py @@ -1,8 +1,8 @@ from ducktools.classbuilder import ( - eq_desc, + eq_maker, fieldclass, get_fields, - init_desc, + init_maker, slotclass, Field, SlotFields, @@ -15,7 +15,7 @@ class FieldExt(Field): __slots__ = SlotFields(repr=True) -def repr_exclude_maker(cls): +def repr_exclude_generator(cls): fields = get_fields(cls) # Use getattr with default True for the condition so @@ -33,12 +33,12 @@ def repr_exclude_maker(cls): return code, globs -repr_desc = MethodMaker("__repr__", repr_exclude_maker) +repr_exclude_maker = MethodMaker("__repr__", repr_exclude_generator) if __name__ == "__main__": - methods = frozenset({init_desc, eq_desc, repr_desc}) + methods = frozenset({init_maker, eq_maker, repr_exclude_maker}) @slotclass(methods=methods) class Example: diff --git a/docs_code/docs_ex5_frozen.py b/docs_code/docs_ex5_frozen.py index ba94cc6..6961169 100644 --- a/docs_code/docs_ex5_frozen.py +++ b/docs_code/docs_ex5_frozen.py @@ -2,12 +2,12 @@ slotclass, SlotFields, default_methods, - frozen_setattr_desc, - frozen_delattr_desc, + frozen_setattr_maker, + frozen_delattr_maker, ) -new_methods = default_methods | {frozen_setattr_desc, frozen_delattr_desc} +new_methods = default_methods | {frozen_setattr_maker, frozen_delattr_maker} def frozen(cls, /): diff --git a/docs_code/docs_ex7_posonly.py b/docs_code/docs_ex7_posonly.py index cbc810d..6835e0d 100644 --- a/docs_code/docs_ex7_posonly.py +++ b/docs_code/docs_ex7_posonly.py @@ -1,6 +1,6 @@ from ducktools.classbuilder import ( builder, - eq_desc, + eq_maker, fieldclass, get_fields, slot_gatherer, @@ -16,7 +16,7 @@ class PosOnlyField(Field): __slots__ = SlotFields(pos_only=True) -def init_maker(cls): +def init_generator(cls): fields = get_fields(cls) arglist = [] @@ -54,7 +54,7 @@ def init_maker(cls): return code, globs -def repr_maker(cls): +def repr_generator(cls): fields = get_fields(cls) content_list = [] for name, field in fields.items(): @@ -73,9 +73,9 @@ def repr_maker(cls): return code, globs -init_desc = MethodMaker("__init__", init_maker) -repr_desc = MethodMaker("__repr__", repr_maker) -new_methods = frozenset({init_desc, repr_desc, eq_desc}) +init_maker = MethodMaker("__init__", init_generator) +repr_maker = MethodMaker("__repr__", repr_generator) +new_methods = frozenset({init_maker, repr_maker, eq_maker}) def pos_slotclass(cls, /): diff --git a/docs_code/docs_ex8_converters.py b/docs_code/docs_ex8_converters.py index 9f8a92d..0832259 100644 --- a/docs_code/docs_ex8_converters.py +++ b/docs_code/docs_ex8_converters.py @@ -15,7 +15,7 @@ class ConverterField(Field): __slots__ = SlotFields(converter=None) -def setattr_maker(cls): +def setattr_generator(cls): fields = get_fields(cls) converters = {} for k, v in fields.items(): @@ -38,8 +38,8 @@ def setattr_maker(cls): return code, globs -setattr_desc = MethodMaker("__setattr__", setattr_maker) -methods = frozenset(default_methods | {setattr_desc}) +setattr_maker = MethodMaker("__setattr__", setattr_generator) +methods = frozenset(default_methods | {setattr_maker}) def converterclass(cls, /): diff --git a/docs_code/docs_ex9_annotated.py b/docs_code/docs_ex9_annotated.py index c176176..be31e0d 100644 --- a/docs_code/docs_ex9_annotated.py +++ b/docs_code/docs_ex9_annotated.py @@ -14,8 +14,7 @@ ) -# New equivalent to dataclasses "Field", these still need to be created -# in order to generate the magic methods correctly. +# First we need a new field that can store these modifications @fieldclass class AnnoField(Field): __slots__ = SlotFields( @@ -26,9 +25,12 @@ class AnnoField(Field): ) -# Modifying objects +# Our 'Annotated' tools need to be combinable and need to contain the keyword argument +# and value they are intended to change. +# To this end we make a FieldModifier class that stores the keyword values given in a +# dictionary as 'modifiers'. This makes it easy to merge modifiers later. class FieldModifier: - __slots__ = ("modifiers", ) + __slots__ = ("modifiers",) modifiers: dict[str, Any] def __init__(self, **modifiers): @@ -46,6 +48,8 @@ def __eq__(self, other): return NotImplemented +# Here we make the modifiers and give them the arguments to Field we +# wish to change with their usage. KW_ONLY = FieldModifier(kw_only=True) NO_INIT = FieldModifier(init=False) NO_REPR = FieldModifier(repr=False) @@ -53,43 +57,49 @@ def __eq__(self, other): IGNORE_ALL = FieldModifier(init=False, repr=False, compare=False) -def annotated_gatherer(cls: type) -> dict[str, Any]: +# Analyse the class and create these new Fields based on the annotations +def annotated_gatherer(cls: type) -> tuple[dict[str, AnnoField], dict[str, Any]]: # String annotations *MUST* be evaluated for this to work - # dataclasses currently does not require this + # Trying to parse the Annotations as strings would add a *lot* of extra work cls_annotations = inspect.get_annotations(cls, eval_str=True) cls_fields = {} + # This gatherer doesn't make any class modifications but still needs + # To have a dict as a return value + cls_modifications = {} + for key, anno in cls_annotations.items(): modifiers = {} - typ = NOTHING if get_origin(anno) is Annotated: - typ = anno.__args__[0] meta = anno.__metadata__ for v in meta: if isinstance(v, FieldModifier): + # Merge the modifier arguments to pass to AnnoField modifiers.update(v.modifiers) - elif not (anno is ClassVar or get_origin(anno) is ClassVar): - typ = anno + # Extract the actual annotation from the first argument + anno = anno.__origin__ - if typ is not NOTHING: - if key in cls.__dict__ and "__slots__" not in cls.__dict__: - val = cls.__dict__[key] - if isinstance(val, Field): - fld = AnnoField.from_field(val, type=typ, **modifiers) - else: - fld = AnnoField(default=val, type=typ, **modifiers) - else: - fld = AnnoField(type=typ, **modifiers) + if anno is ClassVar or get_origin(anno) is ClassVar: + continue - cls_fields[key] = fld + if key in cls.__dict__ and "__slots__" not in cls.__dict__: + val = cls.__dict__[key] + if isinstance(val, Field): + # Make a new field - DO NOT MODIFY FIELDS IN PLACE + fld = AnnoField.from_field(val, type=anno, **modifiers) + else: + fld = AnnoField(default=val, type=anno, **modifiers) + else: + fld = AnnoField(type=anno, **modifiers) - return cls_fields + cls_fields[key] = fld + return cls_fields, cls_modifications -def init_maker(cls): +def init_generator(cls): fields = get_fields(cls) flags = get_flags(cls) @@ -143,7 +153,7 @@ def init_maker(cls): return code, globs -def repr_maker(cls): +def repr_generator(cls): fields = get_fields(cls) content = ", ".join( f"{name}={{self.{name}!r}}" @@ -158,7 +168,7 @@ def repr_maker(cls): return code, globs -def eq_maker(cls): +def eq_generator(cls): class_comparison = "self.__class__ is other.__class__" field_names = [ name @@ -182,11 +192,11 @@ def eq_maker(cls): return code, globs -init_method = MethodMaker("__init__", init_maker) -repr_method = MethodMaker("__repr__", repr_maker) -eq_method = MethodMaker("__eq__", eq_maker) +init_maker = MethodMaker("__init__", init_generator) +repr_maker = MethodMaker("__repr__", repr_generator) +eq_maker = MethodMaker("__eq__", eq_generator) -methods = {init_method, repr_method, eq_method} +methods = {init_maker, repr_maker, eq_maker} def annotationsclass(cls=None, *, kw_only=False): @@ -204,7 +214,8 @@ def annotationsclass(cls=None, *, kw_only=False): @annotationsclass class X: x: str - y: ClassVar[str] = "This is okay" + y: ClassVar[str] = "This should be ignored" + z: Annotated[ClassVar[str], "Should be ignored"] = "This should also be ignored" a: Annotated[int, NO_INIT] = "Not In __init__ signature" b: Annotated[str, NO_REPR] = "Not In Repr" c: Annotated[list[str], NO_COMPARE] = AnnoField(default_factory=list) @@ -218,6 +229,6 @@ class X: pp(get_fields(X)) print("\nSource:") -print(init_maker(X)[0]) -print(eq_maker(X)[0]) -print(repr_maker(X)[0]) +print(init_generator(X)[0]) +print(eq_generator(X)[0]) +print(repr_generator(X)[0]) diff --git a/src/ducktools/classbuilder/__init__.py b/src/ducktools/classbuilder/__init__.py index 32477ec..2f5638b 100644 --- a/src/ducktools/classbuilder/__init__.py +++ b/src/ducktools/classbuilder/__init__.py @@ -21,7 +21,7 @@ # SOFTWARE. import sys -__version__ = "v0.4.0" +__version__ = "v0.5.0" # Change this name if you make heavy modifications INTERNALS_DICT = "__classbuilder_internals__" @@ -65,7 +65,7 @@ def _get_inst_fields(inst): } -# As 'None' can be a meaningful default we need a sentinel value +# As 'None' can be a meaningful value we need a sentinel value # to use to show no value has been provided. class _NothingType: def __repr__(self): @@ -109,7 +109,7 @@ def __get__(self, instance, cls): return method.__get__(instance, cls) -def get_init_maker(null=NOTHING, extra_code=None): +def get_init_generator(null=NOTHING, extra_code=None): def cls_init_maker(cls): fields = get_fields(cls) flags = get_flags(cls) @@ -154,10 +154,10 @@ def cls_init_maker(cls): return cls_init_maker -init_maker = get_init_maker() +init_generator = get_init_generator() -def repr_maker(cls): +def repr_generator(cls): fields = get_fields(cls) content = ", ".join( f"{name}={{self.{name}!r}}" @@ -171,7 +171,7 @@ def repr_maker(cls): return code, globs -def eq_maker(cls): +def eq_generator(cls): class_comparison = "self.__class__ is other.__class__" field_names = get_fields(cls) @@ -191,7 +191,7 @@ def eq_maker(cls): return code, globs -def frozen_setattr_maker(cls): +def frozen_setattr_generator(cls): globs = {} field_names = set(get_fields(cls)) flags = get_flags(cls) @@ -220,7 +220,7 @@ def frozen_setattr_maker(cls): return code, globs -def frozen_delattr_maker(cls): +def frozen_delattr_generator(cls): body = ( ' raise TypeError(\n' ' f"{type(self).__name__!r} object "\n' @@ -234,12 +234,12 @@ def frozen_delattr_maker(cls): # As only the __get__ method refers to the class we can use the same # Descriptor instances for every class. -init_desc = MethodMaker("__init__", init_maker) -repr_desc = MethodMaker("__repr__", repr_maker) -eq_desc = MethodMaker("__eq__", eq_maker) -frozen_setattr_desc = MethodMaker("__setattr__", frozen_setattr_maker) -frozen_delattr_desc = MethodMaker("__delattr__", frozen_delattr_maker) -default_methods = frozenset({init_desc, repr_desc, eq_desc}) +init_maker = MethodMaker("__init__", init_generator) +repr_maker = MethodMaker("__repr__", repr_generator) +eq_maker = MethodMaker("__eq__", eq_generator) +frozen_setattr_maker = MethodMaker("__setattr__", frozen_setattr_generator) +frozen_delattr_maker = MethodMaker("__delattr__", frozen_delattr_generator) +default_methods = frozenset({init_maker, repr_maker, eq_maker}) def builder(cls=None, /, *, gatherer, methods, flags=None): @@ -248,7 +248,7 @@ def builder(cls=None, /, *, gatherer, methods, flags=None): :param cls: Class to be analysed and have methods generated :param gatherer: Function to gather field information - :type gatherer: Callable[[type], dict[str, Field]] + :type gatherer: Callable[[type], tuple[dict[str, Field], dict[str, Any]]] :param methods: MethodMakers to add to the class :type methods: set[MethodMaker] :param flags: additional flags to store in the internals dictionary @@ -267,7 +267,14 @@ def builder(cls=None, /, *, gatherer, methods, flags=None): internals = {} setattr(cls, INTERNALS_DICT, internals) - cls_fields = gatherer(cls) + cls_fields, modifications = gatherer(cls) + + for name, value in modifications.items(): + if value is NOTHING: + delattr(cls, name) + else: + setattr(cls, name, value) + internals["local_fields"] = cls_fields mro = cls.__mro__[:-1] # skip 'object' base class @@ -361,13 +368,13 @@ def from_field(cls, fld, /, **kwargs): "doc": Field(default=None), } -_field_methods = {repr_desc, eq_desc} +_field_methods = {repr_maker, eq_maker} if _UNDER_TESTING: - _field_methods.update({frozen_setattr_desc, frozen_delattr_desc}) + _field_methods.update({frozen_setattr_maker, frozen_delattr_maker}) builder( Field, - gatherer=lambda cls_: _field_internal, + gatherer=lambda cls_: (_field_internal, {}), methods=_field_methods, flags={"slotted": True, "kw_only": True}, ) @@ -390,6 +397,14 @@ class SlotFields(dict): def make_slot_gatherer(field_type=Field): + """ + Create a new annotation gatherer that will work with `Field` instances + of the creators definition. + + :param field_type: The `Field` classes to be used when gathering fields + :return: A slot gatherer that will check for and generate Fields of + the type field_type. + """ def field_slot_gatherer(cls): """ Gather field information for class generation based on __slots__ @@ -405,7 +420,12 @@ def field_slot_gatherer(cls): "in order to generate a slotclass" ) - cls_annotations = cls.__dict__.get("__annotations__", {}) + # Don't want to mutate original annotations so make a copy if it exists + # Looking at the dict is a Python3.9 or earlier requirement + cls_annotations = { + **cls.__dict__.get("__annotations__", {}) + } + cls_fields = {} slot_replacement = {} @@ -421,13 +441,15 @@ def field_slot_gatherer(cls): slot_replacement[k] = attrib.doc cls_fields[k] = attrib - # Replace the SlotAttributes instance with a regular dict - # So that help() works - setattr(cls, "__slots__", slot_replacement) + # Send the modifications to the builder for what should be changed + # On the class. + # In this case, slots with documentation and new annotations. + modifications = { + "__slots__": slot_replacement, + "__annotations__": cls_annotations, + } - # Update annotations with any types from the slots assignment - setattr(cls, "__annotations__", cls_annotations) - return cls_fields + return cls_fields, modifications return field_slot_gatherer @@ -483,6 +505,8 @@ def field_annotation_gatherer(cls): cls_fields: dict[str, field_type] = {} + modifications = {} + for k, v in cls_annotations.items(): # Ignore ClassVar if is_classvar(v): @@ -494,20 +518,21 @@ def field_annotation_gatherer(cls): if isinstance(attrib, field_type): attrib = field_type.from_field(attrib, type=v) if attrib.default is not NOTHING and leave_default_values: - setattr(cls, k, attrib.default) + modifications[k] = attrib.default else: - delattr(cls, k) + # NOTHING sentinel indicates a value should be removed + modifications[k] = NOTHING else: attrib = field_type(default=attrib, type=v) if not leave_default_values: - delattr(cls, k) + modifications[k] = NOTHING else: attrib = field_type(type=v) cls_fields[k] = attrib - return cls_fields + return cls_fields, modifications return field_annotation_gatherer @@ -569,7 +594,7 @@ def annotationclass(cls=None, /, *, methods=default_methods): _field_init_desc = MethodMaker( funcname="__init__", - code_generator=get_init_maker( + code_generator=get_init_generator( null=_NothingType(), extra_code=["self.validate_field()"], ) @@ -590,11 +615,11 @@ def fieldclass(cls=None, /, *, frozen=False): if not cls: return lambda cls_: fieldclass(cls_, frozen=frozen) - field_methods = {_field_init_desc, repr_desc, eq_desc} + field_methods = {_field_init_desc, repr_maker, eq_maker} # Always freeze when running tests if frozen or _UNDER_TESTING: - field_methods.update({frozen_setattr_desc, frozen_delattr_desc}) + field_methods.update({frozen_setattr_maker, frozen_delattr_maker}) cls = builder( cls, diff --git a/src/ducktools/classbuilder/__init__.pyi b/src/ducktools/classbuilder/__init__.pyi index 67318d3..3b03ecc 100644 --- a/src/ducktools/classbuilder/__init__.pyi +++ b/src/ducktools/classbuilder/__init__.pyi @@ -26,24 +26,24 @@ class MethodMaker: def __repr__(self) -> str: ... def __get__(self, instance, cls) -> Callable: ... -def get_init_maker( +def get_init_generator( null: _NothingType = NOTHING, extra_code: None | list[str] = None ) -> Callable[[type], tuple[str, dict[str, typing.Any]]]: ... -def init_maker(cls: type) -> tuple[str, dict[str, typing.Any]]: ... -def repr_maker(cls: type) -> tuple[str, dict[str, typing.Any]]: ... -def eq_maker(cls: type) -> tuple[str, dict[str, typing.Any]]: ... +def init_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ... +def repr_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ... +def eq_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ... -def frozen_setattr_maker(cls: type) -> tuple[str, dict[str, typing.Any]]: ... +def frozen_setattr_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ... -def frozen_delattr_maker(cls: type) -> tuple[str, dict[str, typing.Any]]: ... +def frozen_delattr_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ... -init_desc: MethodMaker -repr_desc: MethodMaker -eq_desc: MethodMaker -frozen_setattr_desc: MethodMaker -frozen_delattr_desc: MethodMaker +init_maker: MethodMaker +repr_maker: MethodMaker +eq_maker: MethodMaker +frozen_setattr_maker: MethodMaker +frozen_delattr_maker: MethodMaker default_methods: frozenset[MethodMaker] _T = typing.TypeVar("_T") @@ -53,7 +53,7 @@ def builder( cls: type[_T], /, *, - gatherer: Callable[[type], dict[str, Field]], + gatherer: Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]], methods: frozenset[MethodMaker] | set[MethodMaker], flags: dict[str, bool] | None = None, ) -> type[_T]: ... @@ -63,7 +63,7 @@ def builder( cls: None = None, /, *, - gatherer: Callable[[type], dict[str, Field]], + gatherer: Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]], methods: frozenset[MethodMaker] | set[MethodMaker], flags: dict[str, bool] | None = None, ) -> Callable[[type[_T]], type[_T]]: ... @@ -95,9 +95,11 @@ class Field: class SlotFields(dict): ... -def make_slot_gatherer(field_type: type[Field] = Field) -> Callable[[type], dict[str, Field]]: ... +def make_slot_gatherer( + field_type: type[Field] = Field +) -> Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]]: ... -def slot_gatherer(cls: type) -> dict[str, Field]: +def slot_gatherer(cls: type) -> tuple[dict[str, Field], dict[str, typing.Any]]: ... def is_classvar(hint: object) -> bool: ... @@ -105,9 +107,9 @@ def is_classvar(hint: object) -> bool: ... def make_annotation_gatherer( field_type: type[Field] = Field, leave_default_values: bool = True, -) -> Callable[[type], dict[str, Field]]: ... +) -> Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]]: ... -def annotation_gatherer(cls: type) -> dict[str, Field]: ... +def annotation_gatherer(cls: type) -> tuple[dict[str, Field], dict[str, typing.Any]]: ... def check_argument_order(cls: type) -> None: ... diff --git a/src/ducktools/classbuilder/prefab.py b/src/ducktools/classbuilder/prefab.py index ae0fe18..e6cd260 100644 --- a/src/ducktools/classbuilder/prefab.py +++ b/src/ducktools/classbuilder/prefab.py @@ -32,7 +32,7 @@ INTERNALS_DICT, NOTHING, Field, MethodMaker, SlotFields, builder, fieldclass, get_flags, get_fields, make_slot_gatherer, - frozen_setattr_desc, frozen_delattr_desc, is_classvar, + frozen_setattr_maker, frozen_delattr_maker, is_classvar, ) PREFAB_FIELDS = "PREFAB_FIELDS" @@ -344,13 +344,13 @@ def as_dict_gen(cls: "type") -> "tuple[str, dict]": return MethodMaker("as_dict", as_dict_gen) -init_desc = get_init_maker() -prefab_init_desc = get_init_maker(init_name=PREFAB_INIT_FUNC) -repr_desc = get_repr_maker() -recursive_repr_desc = get_repr_maker(recursion_safe=True) -eq_desc = get_eq_maker() -iter_desc = get_iter_maker() -asdict_desc = get_asdict_maker() +init_maker = get_init_maker() +prefab_init_maker = get_init_maker(init_name=PREFAB_INIT_FUNC) +repr_maker = get_repr_maker() +recursive_repr_maker = get_repr_maker(recursion_safe=True) +eq_maker = get_eq_maker() +iter_maker = get_iter_maker() +asdict_maker = get_asdict_maker() # Updated field with additional attributes @@ -441,6 +441,8 @@ def attribute_gatherer(cls): cls_attribute_names = cls_attributes.keys() + cls_modifications = {} + if set(cls_annotation_names).issuperset(set(cls_attribute_names)): # replace the classes' attributes dict with one with the correct # order from the annotations. @@ -483,7 +485,7 @@ def attribute_gatherer(cls): # Clear the attribute from the class after it has been used # in the definition. - delattr(cls, name) + cls_modifications[name] = NOTHING else: attrib = attribute(**extras) @@ -493,14 +495,14 @@ def attribute_gatherer(cls): else: for name in cls_attributes.keys(): attrib = cls_attributes[name] - delattr(cls, name) # clear attrib from class + cls_modifications[name] = NOTHING # Some items can still be annotated. if name in cls_annotations: new_attrib = Attribute.from_field(attrib, type=cls_annotations[name]) cls_attributes[name] = new_attrib - return cls_attributes + return cls_attributes, cls_modifications # Class Builders @@ -554,24 +556,24 @@ def _make_prefab( methods = set() if init and "__init__" not in cls_dict: - methods.add(init_desc) + methods.add(init_maker) else: - methods.add(prefab_init_desc) + methods.add(prefab_init_maker) if repr and "__repr__" not in cls_dict: if recursive_repr: - methods.add(recursive_repr_desc) + methods.add(recursive_repr_maker) else: - methods.add(repr_desc) + methods.add(repr_maker) if eq and "__eq__" not in cls_dict: - methods.add(eq_desc) + methods.add(eq_maker) if iter and "__iter__" not in cls_dict: - methods.add(iter_desc) + methods.add(iter_maker) if frozen: - methods.add(frozen_setattr_desc) - methods.add(frozen_delattr_desc) + methods.add(frozen_setattr_maker) + methods.add(frozen_delattr_maker) if dict_method: - methods.add(asdict_desc) + methods.add(asdict_maker) flags = { "kw_only": kw_only, @@ -847,17 +849,17 @@ def as_dict(o): :param o: instance of a prefab class :return: dictionary of {k: v} from fields """ + cls = type(o) + if not hasattr(cls, PREFAB_FIELDS): + raise TypeError(f"{o!r} should be a prefab instance, not {cls}") + # Attempt to use the generated method if available try: return o.as_dict() except AttributeError: pass - cls = type(o) - try: - flds = get_attributes(cls) - except AttributeError: - raise TypeError(f"inst should be a prefab instance, not {cls}") + flds = get_attributes(cls) return { name: getattr(o, name) diff --git a/src/ducktools/classbuilder/prefab.pyi b/src/ducktools/classbuilder/prefab.pyi index bfbd30b..ed94b5a 100644 --- a/src/ducktools/classbuilder/prefab.pyi +++ b/src/ducktools/classbuilder/prefab.pyi @@ -6,7 +6,7 @@ from collections.abc import Callable from . import ( INTERNALS_DICT, NOTHING, Field, MethodMaker, SlotFields as SlotFields, - builder, fieldclass, get_flags, get_fields, slot_gatherer + builder, fieldclass, get_flags, get_fields, make_slot_gatherer ) # noinspection PyUnresolvedReferences @@ -39,13 +39,13 @@ def get_iter_maker() -> MethodMaker: ... def get_asdict_maker() -> MethodMaker: ... -init_desc: MethodMaker -prefab_init_desc: MethodMaker -repr_desc: MethodMaker -recursive_repr_desc: MethodMaker -eq_desc: MethodMaker -iter_desc: MethodMaker -asdict_desc: MethodMaker +init_maker: MethodMaker +prefab_init_maker: MethodMaker +repr_maker: MethodMaker +recursive_repr_maker: MethodMaker +eq_maker: MethodMaker +iter_maker: MethodMaker +asdict_maker: MethodMaker class Attribute(Field): __slots__: dict @@ -93,9 +93,9 @@ def attribute( exclude_field: bool = False, ) -> Attribute: ... -def slot_prefab_gatherer(cls: type) -> dict[str, Attribute]: ... +def slot_prefab_gatherer(cls: type) -> tuple[dict[str, Attribute], dict[str, typing.Any]]: ... -def attribute_gatherer(cls: type) -> dict[str, Attribute]: ... +def attribute_gatherer(cls: type) -> tuple[dict[str, Attribute], dict[str, typing.Any]]: ... def _make_prefab( cls: type, diff --git a/tests/test_annotated.py b/tests/test_annotated.py index fb957f7..7068367 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -4,7 +4,7 @@ from typing import ClassVar from typing_extensions import Annotated -from ducktools.classbuilder import Field, SlotFields, fieldclass +from ducktools.classbuilder import Field, SlotFields, fieldclass, NOTHING from ducktools.classbuilder import ( is_classvar, @@ -44,7 +44,7 @@ class ExampleAnnotated: g: Annotated[Annotated[ClassVar[str], ""], ""] = "g" h: Annotated[CV[str], ''] = "h" - annos = annotation_gatherer(ExampleAnnotated) + annos, modifications = annotation_gatherer(ExampleAnnotated) # ClassVar values ignored in gathering # Instance variables removed from class @@ -56,8 +56,7 @@ class ExampleAnnotated: # Instance variables not removed from class # Field replaced with default value on class - for key in "abcdefgh": - assert ExampleAnnotated.__dict__[key] == key + assert modifications["c"] == "c" def test_make_annotation_gatherer(): @@ -82,23 +81,20 @@ class ExampleAnnotated: g: Annotated[Annotated[ClassVar[str], ""], ""] = "g" h: Annotated[CV[str], ''] = "h" - annos = gatherer(ExampleAnnotated) + annos, modifications = gatherer(ExampleAnnotated) annotations = ExampleAnnotated.__annotations__ assert annos["blank_field"] == NewField(type=str) - # ClassVar values ignored in gathering + # ABC should be present in annos but removed from the class for key in "abc": assert annos[key] == NewField(default=key, type=annotations[key]) - assert key not in ExampleAnnotated.__dict__ + assert modifications[key] is NOTHING + # Opposite for classvar for key in "defgh": assert key not in annos - - # Instance variables not removed from class - # NewField replaced with default value on class - for key in "defgh": - assert ExampleAnnotated.__dict__[key] == key + assert key not in modifications def test_annotationclass(): diff --git a/tests/test_core.py b/tests/test_core.py index 81fda7e..0d7efd2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -7,7 +7,7 @@ get_fields, get_flags, MethodMaker, - init_desc, + init_maker, builder, Field, SlotFields, @@ -156,6 +156,8 @@ def test_slot_gatherer_success(): } class SlotsExample: + a: int + __slots__ = SlotFields( a=1, b=Field(default=2), @@ -163,11 +165,12 @@ class SlotsExample: d=Field(type=str), ) - slots = slot_gatherer(SlotsExample) + slots, modifications = slot_gatherer(SlotsExample) assert slots == fields - assert SlotsExample.__slots__ == {"a": None, "b": None, "c": "a list", "d": None} - assert SlotsExample.__annotations__ == {"d": str} + assert modifications["__slots__"] == {"a": None, "b": None, "c": "a list", "d": None} + assert modifications["__annotations__"] == {"a": int, "d": str} + assert SlotsExample.__annotations__ == {"a": int} # Original annotations dict unmodified def test_slot_gatherer_failure(): @@ -284,7 +287,7 @@ class OrderingError: def test_slotclass_norepr_noeq(): - @slotclass(methods={init_desc}) + @slotclass(methods={init_maker}) class SlotClass: __slots__ = SlotFields( a=Field(), @@ -353,7 +356,7 @@ class NewField(Field): def test_builder_noclass(): - mini_slotclass = builder(gatherer=slot_gatherer, methods={init_desc}) + mini_slotclass = builder(gatherer=slot_gatherer, methods={init_maker}) @mini_slotclass class SlotClass: