ducktools-classbuilder 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ducktools-classbuilder might be problematic. Click here for more details.

@@ -19,12 +19,19 @@
19
19
  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  # SOFTWARE.
22
- __version__ = "v0.3.0"
22
+ import sys
23
+
24
+ __version__ = "v0.5.0"
23
25
 
24
26
  # Change this name if you make heavy modifications
25
27
  INTERNALS_DICT = "__classbuilder_internals__"
26
28
 
27
29
 
30
+ # If testing, make Field classes frozen to make sure attributes are not
31
+ # overwritten. When running this is a performance penalty so it is not required.
32
+ _UNDER_TESTING = "pytest" in sys.modules
33
+
34
+
28
35
  def get_fields(cls, *, local=False):
29
36
  """
30
37
  Utility function to gather the fields dictionary
@@ -58,7 +65,7 @@ def _get_inst_fields(inst):
58
65
  }
59
66
 
60
67
 
61
- # As 'None' can be a meaningful default we need a sentinel value
68
+ # As 'None' can be a meaningful value we need a sentinel value
62
69
  # to use to show no value has been provided.
63
70
  class _NothingType:
64
71
  def __repr__(self):
@@ -102,49 +109,55 @@ class MethodMaker:
102
109
  return method.__get__(instance, cls)
103
110
 
104
111
 
105
- def init_maker(cls, *, null=NOTHING, extra_code=None):
106
- fields = get_fields(cls)
107
- flags = get_flags(cls)
112
+ def get_init_generator(null=NOTHING, extra_code=None):
113
+ def cls_init_maker(cls):
114
+ fields = get_fields(cls)
115
+ flags = get_flags(cls)
108
116
 
109
- arglist = []
110
- assignments = []
111
- globs = {}
117
+ arglist = []
118
+ assignments = []
119
+ globs = {}
112
120
 
113
- if flags.get("kw_only", False):
114
- arglist.append("*")
121
+ if flags.get("kw_only", False):
122
+ arglist.append("*")
115
123
 
116
- for k, v in fields.items():
117
- if v.default is not null:
118
- globs[f"_{k}_default"] = v.default
119
- arg = f"{k}=_{k}_default"
120
- assignment = f"self.{k} = {k}"
121
- elif v.default_factory is not null:
122
- globs[f"_{k}_factory"] = v.default_factory
123
- arg = f"{k}=None"
124
- assignment = f"self.{k} = _{k}_factory() if {k} is None else {k}"
125
- else:
126
- arg = f"{k}"
127
- assignment = f"self.{k} = {k}"
124
+ for k, v in fields.items():
125
+ if v.default is not null:
126
+ globs[f"_{k}_default"] = v.default
127
+ arg = f"{k}=_{k}_default"
128
+ assignment = f"self.{k} = {k}"
129
+ elif v.default_factory is not null:
130
+ globs[f"_{k}_factory"] = v.default_factory
131
+ arg = f"{k}=None"
132
+ assignment = f"self.{k} = _{k}_factory() if {k} is None else {k}"
133
+ else:
134
+ arg = f"{k}"
135
+ assignment = f"self.{k} = {k}"
128
136
 
129
- arglist.append(arg)
130
- assignments.append(assignment)
137
+ arglist.append(arg)
138
+ assignments.append(assignment)
131
139
 
132
- args = ", ".join(arglist)
133
- assigns = "\n ".join(assignments) if assignments else "pass\n"
134
- code = (
135
- f"def __init__(self, {args}):\n"
136
- f" {assigns}\n"
137
- )
138
- # Handle additional function calls
139
- # Used for validate_field on fieldclasses
140
- if extra_code:
141
- for line in extra_code:
142
- code += f" {line}\n"
140
+ args = ", ".join(arglist)
141
+ assigns = "\n ".join(assignments) if assignments else "pass\n"
142
+ code = (
143
+ f"def __init__(self, {args}):\n"
144
+ f" {assigns}\n"
145
+ )
146
+ # Handle additional function calls
147
+ # Used for validate_field on fieldclasses
148
+ if extra_code:
149
+ for line in extra_code:
150
+ code += f" {line}\n"
143
151
 
144
- return code, globs
152
+ return code, globs
153
+
154
+ return cls_init_maker
155
+
156
+
157
+ init_generator = get_init_generator()
145
158
 
146
159
 
147
- def repr_maker(cls):
160
+ def repr_generator(cls):
148
161
  fields = get_fields(cls)
149
162
  content = ", ".join(
150
163
  f"{name}={{self.{name}!r}}"
@@ -158,7 +171,7 @@ def repr_maker(cls):
158
171
  return code, globs
159
172
 
160
173
 
161
- def eq_maker(cls):
174
+ def eq_generator(cls):
162
175
  class_comparison = "self.__class__ is other.__class__"
163
176
  field_names = get_fields(cls)
164
177
 
@@ -178,12 +191,55 @@ def eq_maker(cls):
178
191
  return code, globs
179
192
 
180
193
 
194
+ def frozen_setattr_generator(cls):
195
+ globs = {}
196
+ field_names = set(get_fields(cls))
197
+ flags = get_flags(cls)
198
+
199
+ globs["__field_names"] = field_names
200
+
201
+ # Better to be safe and use the method that works in both cases
202
+ # if somehow slotted has not been set.
203
+ if flags.get("slotted", True):
204
+ globs["__setattr_func"] = object.__setattr__
205
+ setattr_method = "__setattr_func(self, name, value)"
206
+ else:
207
+ setattr_method = "self.__dict__[name] = value"
208
+
209
+ body = (
210
+ f" if hasattr(self, name) or name not in __field_names:\n"
211
+ f' raise TypeError(\n'
212
+ f' f"{{type(self).__name__!r}} object does not support "'
213
+ f' f"attribute assignment"\n'
214
+ f' )\n'
215
+ f" else:\n"
216
+ f" {setattr_method}\n"
217
+ )
218
+ code = f"def __setattr__(self, name, value):\n{body}"
219
+
220
+ return code, globs
221
+
222
+
223
+ def frozen_delattr_generator(cls):
224
+ body = (
225
+ ' raise TypeError(\n'
226
+ ' f"{type(self).__name__!r} object "\n'
227
+ ' f"does not support attribute deletion"\n'
228
+ ' )\n'
229
+ )
230
+ code = f"def __delattr__(self, name):\n{body}"
231
+ globs = {}
232
+ return code, globs
233
+
234
+
181
235
  # As only the __get__ method refers to the class we can use the same
182
236
  # Descriptor instances for every class.
183
- init_desc = MethodMaker("__init__", init_maker)
184
- repr_desc = MethodMaker("__repr__", repr_maker)
185
- eq_desc = MethodMaker("__eq__", eq_maker)
186
- default_methods = frozenset({init_desc, repr_desc, eq_desc})
237
+ init_maker = MethodMaker("__init__", init_generator)
238
+ repr_maker = MethodMaker("__repr__", repr_generator)
239
+ eq_maker = MethodMaker("__eq__", eq_generator)
240
+ frozen_setattr_maker = MethodMaker("__setattr__", frozen_setattr_generator)
241
+ frozen_delattr_maker = MethodMaker("__delattr__", frozen_delattr_generator)
242
+ default_methods = frozenset({init_maker, repr_maker, eq_maker})
187
243
 
188
244
 
189
245
  def builder(cls=None, /, *, gatherer, methods, flags=None):
@@ -192,7 +248,7 @@ def builder(cls=None, /, *, gatherer, methods, flags=None):
192
248
 
193
249
  :param cls: Class to be analysed and have methods generated
194
250
  :param gatherer: Function to gather field information
195
- :type gatherer: Callable[[type], dict[str, Field]]
251
+ :type gatherer: Callable[[type], tuple[dict[str, Field], dict[str, Any]]]
196
252
  :param methods: MethodMakers to add to the class
197
253
  :type methods: set[MethodMaker]
198
254
  :param flags: additional flags to store in the internals dictionary
@@ -211,7 +267,14 @@ def builder(cls=None, /, *, gatherer, methods, flags=None):
211
267
  internals = {}
212
268
  setattr(cls, INTERNALS_DICT, internals)
213
269
 
214
- cls_fields = gatherer(cls)
270
+ cls_fields, modifications = gatherer(cls)
271
+
272
+ for name, value in modifications.items():
273
+ if value is NOTHING:
274
+ delattr(cls, name)
275
+ else:
276
+ setattr(cls, name, value)
277
+
215
278
  internals["local_fields"] = cls_fields
216
279
 
217
280
  mro = cls.__mro__[:-1] # skip 'object' base class
@@ -244,6 +307,8 @@ class Field:
244
307
  some metadata.
245
308
 
246
309
  Intended to be extendable by subclasses for additional features.
310
+
311
+ Note: When run under `pytest`, Field instances are Frozen.
247
312
  """
248
313
  __slots__ = {
249
314
  "default": "Standard default value to be used for attributes with"
@@ -303,14 +368,19 @@ _field_internal = {
303
368
  "doc": Field(default=None),
304
369
  }
305
370
 
371
+ _field_methods = {repr_maker, eq_maker}
372
+ if _UNDER_TESTING:
373
+ _field_methods.update({frozen_setattr_maker, frozen_delattr_maker})
374
+
306
375
  builder(
307
376
  Field,
308
- gatherer=lambda cls_: _field_internal,
309
- methods=frozenset({repr_desc, eq_desc}),
377
+ gatherer=lambda cls_: (_field_internal, {}),
378
+ methods=_field_methods,
310
379
  flags={"slotted": True, "kw_only": True},
311
380
  )
312
381
 
313
382
 
383
+ # Slot gathering tools
314
384
  # Subclass of dict to be identifiable by isinstance checks
315
385
  # For anything more complicated this could be made into a Mapping
316
386
  class SlotFields(dict):
@@ -326,46 +396,170 @@ class SlotFields(dict):
326
396
  """
327
397
 
328
398
 
329
- def slot_gatherer(cls):
399
+ def make_slot_gatherer(field_type=Field):
330
400
  """
331
- Gather field information for class generation based on __slots__
332
-
333
- :param cls: Class to gather field information from
334
- :return: dict of field_name: Field(...)
401
+ Create a new annotation gatherer that will work with `Field` instances
402
+ of the creators definition.
403
+
404
+ :param field_type: The `Field` classes to be used when gathering fields
405
+ :return: A slot gatherer that will check for and generate Fields of
406
+ the type field_type.
335
407
  """
336
- cls_slots = cls.__dict__.get("__slots__", None)
408
+ def field_slot_gatherer(cls):
409
+ """
410
+ Gather field information for class generation based on __slots__
411
+
412
+ :param cls: Class to gather field information from
413
+ :return: dict of field_name: Field(...)
414
+ """
415
+ cls_slots = cls.__dict__.get("__slots__", None)
416
+
417
+ if not isinstance(cls_slots, SlotFields):
418
+ raise TypeError(
419
+ "__slots__ must be an instance of SlotFields "
420
+ "in order to generate a slotclass"
421
+ )
422
+
423
+ # Don't want to mutate original annotations so make a copy if it exists
424
+ # Looking at the dict is a Python3.9 or earlier requirement
425
+ cls_annotations = {
426
+ **cls.__dict__.get("__annotations__", {})
427
+ }
428
+
429
+ cls_fields = {}
430
+ slot_replacement = {}
431
+
432
+ for k, v in cls_slots.items():
433
+ if isinstance(v, field_type):
434
+ attrib = v
435
+ if attrib.type is not NOTHING:
436
+ cls_annotations[k] = attrib.type
437
+ else:
438
+ # Plain values treated as defaults
439
+ attrib = field_type(default=v)
440
+
441
+ slot_replacement[k] = attrib.doc
442
+ cls_fields[k] = attrib
443
+
444
+ # Send the modifications to the builder for what should be changed
445
+ # On the class.
446
+ # In this case, slots with documentation and new annotations.
447
+ modifications = {
448
+ "__slots__": slot_replacement,
449
+ "__annotations__": cls_annotations,
450
+ }
451
+
452
+ return cls_fields, modifications
453
+
454
+ return field_slot_gatherer
337
455
 
338
- if not isinstance(cls_slots, SlotFields):
339
- raise TypeError(
340
- "__slots__ must be an instance of SlotFields "
341
- "in order to generate a slotclass"
342
- )
343
456
 
344
- cls_annotations = cls.__dict__.get("__annotations__", {})
345
- cls_fields = {}
346
- slot_replacement = {}
457
+ slot_gatherer = make_slot_gatherer()
347
458
 
348
- for k, v in cls_slots.items():
349
- if isinstance(v, Field):
350
- attrib = v
351
- if v.type is not NOTHING:
352
- cls_annotations[k] = attrib.type
459
+
460
+ # Annotation gathering tools
461
+ def is_classvar(hint):
462
+ _typing = sys.modules.get("typing")
463
+
464
+ if _typing:
465
+ # Annotated is a nightmare I'm never waking up from
466
+ # 3.8 and 3.9 need Annotated from typing_extensions
467
+ # 3.8 also needs get_origin from typing_extensions
468
+ if sys.version_info < (3, 10):
469
+ _typing_extensions = sys.modules.get("typing_extensions")
470
+ if _typing_extensions:
471
+ _Annotated = _typing_extensions.Annotated
472
+ _get_origin = _typing_extensions.get_origin
473
+ else:
474
+ _Annotated, _get_origin = None, None
353
475
  else:
354
- # Plain values treated as defaults
355
- attrib = Field(default=v)
476
+ _Annotated = _typing.Annotated
477
+ _get_origin = _typing.get_origin
478
+
479
+ if _Annotated and _get_origin(hint) is _Annotated:
480
+ hint = getattr(hint, "__origin__", None)
481
+
482
+ if (
483
+ hint is _typing.ClassVar
484
+ or getattr(hint, "__origin__", None) is _typing.ClassVar
485
+ ):
486
+ return True
487
+ # String used as annotation
488
+ elif isinstance(hint, str) and "ClassVar" in hint:
489
+ return True
490
+ return False
491
+
492
+
493
+ def make_annotation_gatherer(field_type=Field, leave_default_values=True):
494
+ """
495
+ Create a new annotation gatherer that will work with `Field` instances
496
+ of the creators definition.
497
+
498
+ :param field_type: The `Field` classes to be used when gathering fields
499
+ :param leave_default_values: Set to True if the gatherer should leave
500
+ default values in place as class variables.
501
+ :return: An annotation gatherer with these settings.
502
+ """
503
+ def field_annotation_gatherer(cls):
504
+ cls_annotations = cls.__dict__.get("__annotations__", {})
505
+
506
+ cls_fields: dict[str, field_type] = {}
507
+
508
+ modifications = {}
509
+
510
+ for k, v in cls_annotations.items():
511
+ # Ignore ClassVar
512
+ if is_classvar(v):
513
+ continue
514
+
515
+ attrib = getattr(cls, k, NOTHING)
516
+
517
+ if attrib is not NOTHING:
518
+ if isinstance(attrib, field_type):
519
+ attrib = field_type.from_field(attrib, type=v)
520
+ if attrib.default is not NOTHING and leave_default_values:
521
+ modifications[k] = attrib.default
522
+ else:
523
+ # NOTHING sentinel indicates a value should be removed
524
+ modifications[k] = NOTHING
525
+ else:
526
+ attrib = field_type(default=attrib, type=v)
527
+ if not leave_default_values:
528
+ modifications[k] = NOTHING
529
+
530
+ else:
531
+ attrib = field_type(type=v)
532
+
533
+ cls_fields[k] = attrib
356
534
 
357
- slot_replacement[k] = attrib.doc
358
- cls_fields[k] = attrib
535
+ return cls_fields, modifications
359
536
 
360
- # Replace the SlotAttributes instance with a regular dict
361
- # So that help() works
362
- setattr(cls, "__slots__", slot_replacement)
537
+ return field_annotation_gatherer
363
538
 
364
- # Update annotations with any types from the slots assignment
365
- setattr(cls, "__annotations__", cls_annotations)
366
- return cls_fields
367
539
 
540
+ annotation_gatherer = make_annotation_gatherer()
368
541
 
542
+
543
+ def check_argument_order(cls):
544
+ """
545
+ Raise a SyntaxError if the argument order will be invalid for a generated
546
+ `__init__` function.
547
+
548
+ :param cls: class being built
549
+ """
550
+ fields = get_fields(cls)
551
+ used_default = False
552
+ for k, v in fields.items():
553
+ if v.default is NOTHING and v.default_factory is NOTHING:
554
+ if used_default:
555
+ raise SyntaxError(
556
+ f"non-default argument {k!r} follows default argument"
557
+ )
558
+ else:
559
+ used_default = True
560
+
561
+
562
+ # Class Decorators
369
563
  def slotclass(cls=None, /, *, methods=default_methods, syntax_check=True):
370
564
  """
371
565
  Example of class builder in action using __slots__ to find fields.
@@ -382,40 +576,50 @@ def slotclass(cls=None, /, *, methods=default_methods, syntax_check=True):
382
576
  cls = builder(cls, gatherer=slot_gatherer, methods=methods, flags={"slotted": True})
383
577
 
384
578
  if syntax_check:
385
- fields = get_fields(cls)
386
- used_default = False
387
- for k, v in fields.items():
388
- if v.default is NOTHING and v.default_factory is NOTHING:
389
- if used_default:
390
- raise SyntaxError(
391
- f"non-default argument {k!r} follows default argument"
392
- )
393
- else:
394
- used_default = True
579
+ check_argument_order(cls)
395
580
 
396
581
  return cls
397
582
 
398
583
 
399
- def _field_init_func(cls):
400
- # Fields need a different Nothing for their __init__ generation
401
- # And an extra call to validate_field
402
- field_nothing = _NothingType()
403
- extra_calls = ["self.validate_field()"]
404
- return init_maker(cls, null=field_nothing, extra_code=extra_calls)
584
+ def annotationclass(cls=None, /, *, methods=default_methods):
585
+ if not cls:
586
+ return lambda cls_: annotationclass(cls_, methods=methods)
587
+
588
+ cls = builder(cls, gatherer=annotation_gatherer, methods=methods, flags={"slotted": False})
589
+
590
+ check_argument_order(cls)
591
+
592
+ return cls
405
593
 
406
594
 
407
- def fieldclass(cls):
595
+ _field_init_desc = MethodMaker(
596
+ funcname="__init__",
597
+ code_generator=get_init_generator(
598
+ null=_NothingType(),
599
+ extra_code=["self.validate_field()"],
600
+ )
601
+ )
602
+
603
+
604
+ def fieldclass(cls=None, /, *, frozen=False):
408
605
  """
409
606
  This is a special decorator for making Field subclasses using __slots__.
410
607
  This works by forcing the __init__ method to treat NOTHING as a regular
411
608
  value. This means *all* instance attributes always have defaults.
412
609
 
413
610
  :param cls: Field subclass
611
+ :param frozen: Make the field class a frozen class.
612
+ Field classes are always frozen when running under `pytest`
414
613
  :return: Modified subclass
415
614
  """
615
+ if not cls:
616
+ return lambda cls_: fieldclass(cls_, frozen=frozen)
617
+
618
+ field_methods = {_field_init_desc, repr_maker, eq_maker}
416
619
 
417
- field_init_desc = MethodMaker("__init__", _field_init_func)
418
- field_methods = frozenset({field_init_desc, repr_desc, eq_desc})
620
+ # Always freeze when running tests
621
+ if frozen or _UNDER_TESTING:
622
+ field_methods.update({frozen_setattr_maker, frozen_delattr_maker})
419
623
 
420
624
  cls = builder(
421
625
  cls,
@@ -26,18 +26,24 @@ class MethodMaker:
26
26
  def __repr__(self) -> str: ...
27
27
  def __get__(self, instance, cls) -> Callable: ...
28
28
 
29
- def init_maker(
30
- cls: type,
31
- *,
29
+ def get_init_generator(
32
30
  null: _NothingType = NOTHING,
33
31
  extra_code: None | list[str] = None
34
- ) -> tuple[str, dict[str, typing.Any]]: ...
35
- def repr_maker(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
36
- def eq_maker(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
32
+ ) -> Callable[[type], tuple[str, dict[str, typing.Any]]]: ...
33
+
34
+ def init_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
35
+ def repr_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
36
+ def eq_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
37
+
38
+ def frozen_setattr_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
37
39
 
38
- init_desc: MethodMaker
39
- repr_desc: MethodMaker
40
- eq_desc: MethodMaker
40
+ def frozen_delattr_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
41
+
42
+ init_maker: MethodMaker
43
+ repr_maker: MethodMaker
44
+ eq_maker: MethodMaker
45
+ frozen_setattr_maker: MethodMaker
46
+ frozen_delattr_maker: MethodMaker
41
47
  default_methods: frozenset[MethodMaker]
42
48
 
43
49
  _T = typing.TypeVar("_T")
@@ -47,7 +53,7 @@ def builder(
47
53
  cls: type[_T],
48
54
  /,
49
55
  *,
50
- gatherer: Callable[[type], dict[str, Field]],
56
+ gatherer: Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]],
51
57
  methods: frozenset[MethodMaker] | set[MethodMaker],
52
58
  flags: dict[str, bool] | None = None,
53
59
  ) -> type[_T]: ...
@@ -57,7 +63,7 @@ def builder(
57
63
  cls: None = None,
58
64
  /,
59
65
  *,
60
- gatherer: Callable[[type], dict[str, Field]],
66
+ gatherer: Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]],
61
67
  methods: frozenset[MethodMaker] | set[MethodMaker],
62
68
  flags: dict[str, bool] | None = None,
63
69
  ) -> Callable[[type[_T]], type[_T]]: ...
@@ -89,9 +95,24 @@ class Field:
89
95
  class SlotFields(dict):
90
96
  ...
91
97
 
92
- def slot_gatherer(cls: type) -> dict[str, Field]:
98
+ def make_slot_gatherer(
99
+ field_type: type[Field] = Field
100
+ ) -> Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]]: ...
101
+
102
+ def slot_gatherer(cls: type) -> tuple[dict[str, Field], dict[str, typing.Any]]:
93
103
  ...
94
104
 
105
+ def is_classvar(hint: object) -> bool: ...
106
+
107
+ def make_annotation_gatherer(
108
+ field_type: type[Field] = Field,
109
+ leave_default_values: bool = True,
110
+ ) -> Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]]: ...
111
+
112
+ def annotation_gatherer(cls: type) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...
113
+
114
+ def check_argument_order(cls: type) -> None: ...
115
+
95
116
  @typing.overload
96
117
  def slotclass(
97
118
  cls: type[_T],
@@ -110,4 +131,24 @@ def slotclass(
110
131
  syntax_check: bool = True
111
132
  ) -> Callable[[type[_T]], type[_T]]: ...
112
133
 
113
- def fieldclass(cls: type[_T]) -> type[_T]: ...
134
+ @typing.overload
135
+ def annotationclass(
136
+ cls: type[_T],
137
+ /,
138
+ *,
139
+ methods: frozenset[MethodMaker] | set[MethodMaker] = default_methods,
140
+ ) -> type[_T]: ...
141
+
142
+ @typing.overload
143
+ def annotationclass(
144
+ cls: None = None,
145
+ /,
146
+ *,
147
+ methods: frozenset[MethodMaker] | set[MethodMaker] = default_methods,
148
+ ) -> Callable[[type[_T]], type[_T]]: ...
149
+
150
+ @typing.overload
151
+ def fieldclass(cls: type[_T], /, *, frozen: bool = False) -> type[_T]: ...
152
+
153
+ @typing.overload
154
+ def fieldclass(cls: None = None, /, *, frozen: bool = False) -> Callable[[type[_T]], type[_T]]: ...
@@ -31,7 +31,8 @@ import sys
31
31
  from . import (
32
32
  INTERNALS_DICT, NOTHING,
33
33
  Field, MethodMaker, SlotFields,
34
- builder, fieldclass, get_flags, get_fields, slot_gatherer
34
+ builder, fieldclass, get_flags, get_fields, make_slot_gatherer,
35
+ frozen_setattr_maker, frozen_delattr_maker, is_classvar,
35
36
  )
36
37
 
37
38
  PREFAB_FIELDS = "PREFAB_FIELDS"
@@ -55,20 +56,6 @@ class PrefabError(Exception):
55
56
  pass
56
57
 
57
58
 
58
- def _is_classvar(hint):
59
- _typing = sys.modules.get("typing")
60
- if _typing:
61
- if (
62
- hint is _typing.ClassVar
63
- or getattr(hint, "__origin__", None) is _typing.ClassVar
64
- ):
65
- return True
66
- # String used as annotation
67
- elif isinstance(hint, str) and "ClassVar" in hint:
68
- return True
69
- return False
70
-
71
-
72
59
  def get_attributes(cls):
73
60
  """
74
61
  Copy of get_fields, typed to return Attribute instead of Field.
@@ -340,57 +327,6 @@ def get_iter_maker():
340
327
  return MethodMaker("__iter__", __iter__)
341
328
 
342
329
 
343
- def get_frozen_setattr_maker():
344
- def __setattr__(cls: "type") -> "tuple[str, dict]":
345
- globs = {}
346
- attributes = get_attributes(cls)
347
- flags = get_flags(cls)
348
-
349
- # Make the fields set literal
350
- fields_delimited = ", ".join(f"{field!r}" for field in attributes)
351
- field_set = f"{{ {fields_delimited} }}"
352
-
353
- # Better to be safe and use the method that works in both cases
354
- # if somehow slotted has not been set.
355
- if flags.get("slotted", True):
356
- globs["__prefab_setattr_func"] = object.__setattr__
357
- setattr_method = "__prefab_setattr_func(self, name, value)"
358
- else:
359
- setattr_method = "self.__dict__[name] = value"
360
-
361
- body = (
362
- f" if hasattr(self, name) or name not in {field_set}:\n"
363
- f' raise TypeError(\n'
364
- f' f"{{type(self).__name__!r}} object does not support "'
365
- f' f"attribute assignment"\n'
366
- f' )\n'
367
- f" else:\n"
368
- f" {setattr_method}\n"
369
- )
370
- code = f"def __setattr__(self, name, value):\n{body}"
371
-
372
- return code, globs
373
-
374
- # Pass the exception to exec
375
- return MethodMaker("__setattr__", __setattr__)
376
-
377
-
378
- def get_frozen_delattr_maker():
379
- # noinspection PyUnusedLocal
380
- def __delattr__(cls: "type") -> "tuple[str, dict]":
381
- body = (
382
- ' raise TypeError(\n'
383
- ' f"{type(self).__name__!r} object "\n'
384
- ' f"does not support attribute deletion"\n'
385
- ' )\n'
386
- )
387
- code = f"def __delattr__(self, name):\n{body}"
388
- globs = {}
389
- return code, globs
390
-
391
- return MethodMaker("__delattr__", __delattr__)
392
-
393
-
394
330
  def get_asdict_maker():
395
331
  def as_dict_gen(cls: "type") -> "tuple[str, dict]":
396
332
  fields = get_attributes(cls)
@@ -408,15 +344,13 @@ def get_asdict_maker():
408
344
  return MethodMaker("as_dict", as_dict_gen)
409
345
 
410
346
 
411
- init_desc = get_init_maker()
412
- prefab_init_desc = get_init_maker(init_name=PREFAB_INIT_FUNC)
413
- repr_desc = get_repr_maker()
414
- recursive_repr_desc = get_repr_maker(recursion_safe=True)
415
- eq_desc = get_eq_maker()
416
- iter_desc = get_iter_maker()
417
- frozen_setattr_desc = get_frozen_setattr_maker()
418
- frozen_delattr_desc = get_frozen_delattr_maker()
419
- asdict_desc = get_asdict_maker()
347
+ init_maker = get_init_maker()
348
+ prefab_init_maker = get_init_maker(init_name=PREFAB_INIT_FUNC)
349
+ repr_maker = get_repr_maker()
350
+ recursive_repr_maker = get_repr_maker(recursion_safe=True)
351
+ eq_maker = get_eq_maker()
352
+ iter_maker = get_iter_maker()
353
+ asdict_maker = get_asdict_maker()
420
354
 
421
355
 
422
356
  # Updated field with additional attributes
@@ -491,12 +425,7 @@ def attribute(
491
425
  )
492
426
 
493
427
 
494
- def slot_prefab_gatherer(cls):
495
- # For prefabs it's easier if everything is an attribute
496
- return {
497
- name: Attribute.from_field(fld)
498
- for name, fld in slot_gatherer(cls).items()
499
- }
428
+ slot_prefab_gatherer = make_slot_gatherer(Attribute)
500
429
 
501
430
 
502
431
  # Gatherer for classes built on attributes or annotations
@@ -512,6 +441,8 @@ def attribute_gatherer(cls):
512
441
 
513
442
  cls_attribute_names = cls_attributes.keys()
514
443
 
444
+ cls_modifications = {}
445
+
515
446
  if set(cls_annotation_names).issuperset(set(cls_attribute_names)):
516
447
  # replace the classes' attributes dict with one with the correct
517
448
  # order from the annotations.
@@ -519,7 +450,7 @@ def attribute_gatherer(cls):
519
450
  new_attributes = {}
520
451
  for name, value in cls_annotations.items():
521
452
  # Ignore ClassVar hints
522
- if _is_classvar(value):
453
+ if is_classvar(value):
523
454
  continue
524
455
 
525
456
  # Look for the KW_ONLY annotation
@@ -533,39 +464,45 @@ def attribute_gatherer(cls):
533
464
  # Copy attributes that are already defined to the new dict
534
465
  # generate Attribute() values for those that are not defined.
535
466
 
467
+ # Extra parameters to pass to each Attribute
468
+ extras = {
469
+ "type": cls_annotations[name]
470
+ }
471
+ if kw_flag:
472
+ extras["kw_only"] = True
473
+
536
474
  # If a field name is also declared in slots it can't have a real
537
475
  # default value and the attr will be the slot descriptor.
538
476
  if hasattr(cls, name) and name not in cls_slots:
539
477
  if name in cls_attribute_names:
540
- attrib = cls_attributes[name]
478
+ attrib = Attribute.from_field(
479
+ cls_attributes[name],
480
+ **extras,
481
+ )
541
482
  else:
542
483
  attribute_default = getattr(cls, name)
543
- attrib = attribute(default=attribute_default)
484
+ attrib = attribute(default=attribute_default, **extras)
544
485
 
545
486
  # Clear the attribute from the class after it has been used
546
487
  # in the definition.
547
- delattr(cls, name)
488
+ cls_modifications[name] = NOTHING
548
489
  else:
549
- attrib = attribute()
490
+ attrib = attribute(**extras)
550
491
 
551
- if kw_flag:
552
- attrib.kw_only = True
553
-
554
- attrib.type = cls_annotations[name]
555
492
  new_attributes[name] = attrib
556
493
 
557
494
  cls_attributes = new_attributes
558
495
  else:
559
- for name, attrib in cls_attributes.items():
560
- delattr(cls, name)
496
+ for name in cls_attributes.keys():
497
+ attrib = cls_attributes[name]
498
+ cls_modifications[name] = NOTHING
561
499
 
562
500
  # Some items can still be annotated.
563
- try:
564
- attrib.type = cls_annotations[name]
565
- except KeyError:
566
- pass
501
+ if name in cls_annotations:
502
+ new_attrib = Attribute.from_field(attrib, type=cls_annotations[name])
503
+ cls_attributes[name] = new_attrib
567
504
 
568
- return cls_attributes
505
+ return cls_attributes, cls_modifications
569
506
 
570
507
 
571
508
  # Class Builders
@@ -619,24 +556,24 @@ def _make_prefab(
619
556
  methods = set()
620
557
 
621
558
  if init and "__init__" not in cls_dict:
622
- methods.add(init_desc)
559
+ methods.add(init_maker)
623
560
  else:
624
- methods.add(prefab_init_desc)
561
+ methods.add(prefab_init_maker)
625
562
 
626
563
  if repr and "__repr__" not in cls_dict:
627
564
  if recursive_repr:
628
- methods.add(recursive_repr_desc)
565
+ methods.add(recursive_repr_maker)
629
566
  else:
630
- methods.add(repr_desc)
567
+ methods.add(repr_maker)
631
568
  if eq and "__eq__" not in cls_dict:
632
- methods.add(eq_desc)
569
+ methods.add(eq_maker)
633
570
  if iter and "__iter__" not in cls_dict:
634
- methods.add(iter_desc)
571
+ methods.add(iter_maker)
635
572
  if frozen:
636
- methods.add(frozen_setattr_desc)
637
- methods.add(frozen_delattr_desc)
573
+ methods.add(frozen_setattr_maker)
574
+ methods.add(frozen_delattr_maker)
638
575
  if dict_method:
639
- methods.add(asdict_desc)
576
+ methods.add(asdict_maker)
640
577
 
641
578
  flags = {
642
579
  "kw_only": kw_only,
@@ -912,17 +849,17 @@ def as_dict(o):
912
849
  :param o: instance of a prefab class
913
850
  :return: dictionary of {k: v} from fields
914
851
  """
852
+ cls = type(o)
853
+ if not hasattr(cls, PREFAB_FIELDS):
854
+ raise TypeError(f"{o!r} should be a prefab instance, not {cls}")
855
+
915
856
  # Attempt to use the generated method if available
916
857
  try:
917
858
  return o.as_dict()
918
859
  except AttributeError:
919
860
  pass
920
861
 
921
- cls = type(o)
922
- try:
923
- flds = get_attributes(cls)
924
- except AttributeError:
925
- raise TypeError(f"inst should be a prefab instance, not {cls}")
862
+ flds = get_attributes(cls)
926
863
 
927
864
  return {
928
865
  name: getattr(o, name)
@@ -6,7 +6,7 @@ from collections.abc import Callable
6
6
  from . import (
7
7
  INTERNALS_DICT, NOTHING,
8
8
  Field, MethodMaker, SlotFields as SlotFields,
9
- builder, fieldclass, get_flags, get_fields, slot_gatherer
9
+ builder, fieldclass, get_flags, get_fields, make_slot_gatherer
10
10
  )
11
11
 
12
12
  # noinspection PyUnresolvedReferences
@@ -26,8 +26,6 @@ KW_ONLY: _KW_ONLY_TYPE
26
26
 
27
27
  class PrefabError(Exception): ...
28
28
 
29
- def _is_classvar(hint: type | str) -> bool: ...
30
-
31
29
  def get_attributes(cls: type) -> dict[str, Attribute]: ...
32
30
 
33
31
  def get_init_maker(*, init_name: str="__init__") -> MethodMaker: ...
@@ -38,22 +36,16 @@ def get_eq_maker() -> MethodMaker: ...
38
36
 
39
37
  def get_iter_maker() -> MethodMaker: ...
40
38
 
41
- def get_frozen_setattr_maker() -> MethodMaker: ...
42
-
43
- def get_frozen_delattr_maker() -> MethodMaker: ...
44
-
45
39
  def get_asdict_maker() -> MethodMaker: ...
46
40
 
47
41
 
48
- init_desc: MethodMaker
49
- prefab_init_desc: MethodMaker
50
- repr_desc: MethodMaker
51
- recursive_repr_desc: MethodMaker
52
- eq_desc: MethodMaker
53
- iter_desc: MethodMaker
54
- frozen_setattr_desc: MethodMaker
55
- frozen_delattr_desc: MethodMaker
56
- asdict_desc: MethodMaker
42
+ init_maker: MethodMaker
43
+ prefab_init_maker: MethodMaker
44
+ repr_maker: MethodMaker
45
+ recursive_repr_maker: MethodMaker
46
+ eq_maker: MethodMaker
47
+ iter_maker: MethodMaker
48
+ asdict_maker: MethodMaker
57
49
 
58
50
  class Attribute(Field):
59
51
  __slots__: dict
@@ -101,9 +93,9 @@ def attribute(
101
93
  exclude_field: bool = False,
102
94
  ) -> Attribute: ...
103
95
 
104
- def slot_prefab_gatherer(cls: type) -> dict[str, Attribute]: ...
96
+ def slot_prefab_gatherer(cls: type) -> tuple[dict[str, Attribute], dict[str, typing.Any]]: ...
105
97
 
106
- def attribute_gatherer(cls: type) -> dict[str, Attribute]: ...
98
+ def attribute_gatherer(cls: type) -> tuple[dict[str, Attribute], dict[str, typing.Any]]: ...
107
99
 
108
100
  def _make_prefab(
109
101
  cls: type,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ducktools-classbuilder
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Toolkit for creating class boilerplate generators
5
5
  Author: David C Ellis
6
6
  License: MIT License
@@ -45,6 +45,7 @@ Provides-Extra: testing
45
45
  Requires-Dist: pytest ; extra == 'testing'
46
46
  Requires-Dist: pytest-cov ; extra == 'testing'
47
47
  Requires-Dist: mypy ; extra == 'testing'
48
+ Requires-Dist: typing-extensions ; (python_version < "3.10") and extra == 'testing'
48
49
 
49
50
  # Ducktools: Class Builder #
50
51
 
@@ -68,11 +69,12 @@ Install from PyPI with:
68
69
  In order to create a class decorator using `ducktools.classbuilder` there are
69
70
  a few things you need to prepare.
70
71
 
71
- 1. A field gathering function to analyse the class and collect valid `Field`s.
72
+ 1. A field gathering function to analyse the class and collect valid `Field`s and provide
73
+ any modifications that need to be applied to the class attributes.
72
74
  * An example `slot_gatherer` is included.
73
75
  2. Code generators that can make use of the gathered `Field`s to create magic method
74
- source code.
75
- * Example `init_maker`, `repr_maker` and `eq_maker` generators are included.
76
+ source code. To be made into descriptors by `MethodMaker`.
77
+ * Example `init_generator`, `repr_generator` and `eq_generator` generators are included.
76
78
  3. A function that calls the `builder` function to apply both of these steps.
77
79
 
78
80
  A field gathering function needs to take the original class as an argument and
@@ -90,25 +92,26 @@ class[^1] where keyword arguments define the names and values for the fields.
90
92
 
91
93
  Code generator functions need to be converted to descriptors before being used.
92
94
  This is done using the provided `MethodMaker` descriptor class.
93
- ex: `init_desc = MethodMaker("__init__", init_maker)`
95
+ ex: `init_maker = MethodMaker("__init__", init_generator)`.
94
96
 
95
97
  These parts can then be used to make a basic class boilerplate generator by
96
98
  providing them to the `builder` function.
97
99
 
98
100
  ```python
99
101
  from ducktools.classbuilder import (
100
- builder,
101
- slot_gatherer,
102
- init_maker, eq_maker, repr_maker,
102
+ builder,
103
+ slot_gatherer,
104
+ init_generator, eq_generator, repr_generator,
103
105
  MethodMaker,
104
106
  )
105
107
 
106
- init_desc = MethodMaker("__init__", init_maker)
107
- repr_desc = MethodMaker("__repr__", repr_maker)
108
- eq_desc = MethodMaker("__eq__", eq_maker)
108
+ init_maker = MethodMaker("__init__", init_generator)
109
+ repr_maker = MethodMaker("__repr__", repr_generator)
110
+ eq_maker = MethodMaker("__eq__", eq_generator)
111
+
109
112
 
110
113
  def slotclass(cls):
111
- return builder(cls, gatherer=slot_gatherer, methods={init_desc, repr_desc, eq_desc})
114
+ return builder(cls, gatherer=slot_gatherer, methods={init_maker, repr_maker, eq_maker})
112
115
  ```
113
116
 
114
117
  ## Slot Class Usage ##
@@ -121,7 +124,7 @@ a similar manner to the `@dataclass` decorator from `dataclasses`.
121
124
  > be used directly. (The included version has some extra features).
122
125
 
123
126
  ```python
124
- from ducktools.classbuilder import Field, SlotFields
127
+ from ducktools.classbuilder import Field, SlotFields, slotclass
125
128
 
126
129
  @slotclass
127
130
  class SlottedDC:
@@ -206,6 +209,25 @@ print(f"{DataCoords is class_register[DataCoords.__name__] = }")
206
209
  print(f"{SlotCoords is class_register[SlotCoords.__name__] = }")
207
210
  ```
208
211
 
212
+ ## Using annotations anyway ##
213
+
214
+ For those that really want to use type annotations a basic `annotation_gatherer`
215
+ function and `@annotationclass` decorator are also included. Slots are not generated
216
+ in this case.
217
+
218
+ ```python
219
+ from ducktools.classbuilder import annotationclass
220
+
221
+ @annotationclass
222
+ class AnnotatedDC:
223
+ the_answer: int = 42
224
+ the_question: str = "What do you get if you multiply six by nine?"
225
+
226
+
227
+ ex = AnnotatedDC()
228
+ print(ex)
229
+ ```
230
+
209
231
  ## What features does this have? ##
210
232
 
211
233
  Included as an example implementation, the `slotclass` generator supports
@@ -218,6 +240,9 @@ It will copy values provided as the `type` to `Field` into the
218
240
  Values provided to `doc` will be placed in the final `__slots__`
219
241
  field so they are present on the class if `help(...)` is called.
220
242
 
243
+ A fairly basic `annotations_gatherer` and `annotationclass` are also included
244
+ and can be used to generate classbuilders that rely on annotations.
245
+
221
246
  If you want something with more features you can look at the `prefab.py`
222
247
  implementation which provides a 'prebuilt' implementation.
223
248
 
@@ -0,0 +1,10 @@
1
+ ducktools/classbuilder/__init__.py,sha256=aB88mFw6bv5wzlZY88ZoCiWic3YMW3naxXmPhwT_fbs,20045
2
+ ducktools/classbuilder/__init__.pyi,sha256=rFNQYj_TeikQMGV-NCDD4fL9l9od7NkGRoIIyABItoQ,4310
3
+ ducktools/classbuilder/prefab.py,sha256=od-GEAYQokPDUePMOb2zPFGfh5p6zToJ-oEYpZx5CLA,28017
4
+ ducktools/classbuilder/prefab.pyi,sha256=f7GWVTyuQUU6EO6l0eDoiKy4uUq0DxK2ndHhJ0AGeKk,3830
5
+ ducktools/classbuilder/py.typed,sha256=la67KBlbjXN-_-DfGNcdOcjYumVpKG_Tkw-8n5dnGB4,8
6
+ ducktools_classbuilder-0.5.0.dist-info/LICENSE.md,sha256=6Thz9Dbw8R4fWInl6sGl8Rj3UnKnRbDwrc6jZerpugQ,1070
7
+ ducktools_classbuilder-0.5.0.dist-info/METADATA,sha256=50Jn1_0FD2BadATbyBWOQTz3fIxV1cJ36J3frvp8qBY,10143
8
+ ducktools_classbuilder-0.5.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
9
+ ducktools_classbuilder-0.5.0.dist-info/top_level.txt,sha256=uSDLtio3ZFqdwcsMJ2O5yhjB4Q3ytbBWbA8rJREganc,10
10
+ ducktools_classbuilder-0.5.0.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- ducktools/classbuilder/__init__.py,sha256=Vw5RjzzIkXGYXKOmFWTxPsIAM4ceg74Q1Oku4fwF0ek,13505
2
- ducktools/classbuilder/__init__.pyi,sha256=hgcEylMAHgfonOt2fH9OCE3gU60mYkiOUe7e8mTwNVs,2857
3
- ducktools/classbuilder/prefab.py,sha256=gJJCtTAbQLgIoo79jRWVp09r3u8kJjcqXp4G4FTu9j8,29887
4
- ducktools/classbuilder/prefab.pyi,sha256=vcaEpBxyVsOTwkGBkVgmbpn8DPqQqFnAU4xTKPnpOd8,3977
5
- ducktools/classbuilder/py.typed,sha256=la67KBlbjXN-_-DfGNcdOcjYumVpKG_Tkw-8n5dnGB4,8
6
- ducktools_classbuilder-0.3.0.dist-info/LICENSE.md,sha256=6Thz9Dbw8R4fWInl6sGl8Rj3UnKnRbDwrc6jZerpugQ,1070
7
- ducktools_classbuilder-0.3.0.dist-info/METADATA,sha256=sAWw76LgN3HruCS-SXURrrAj2A6MmhsyDJwMSvnWW2w,9280
8
- ducktools_classbuilder-0.3.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
9
- ducktools_classbuilder-0.3.0.dist-info/top_level.txt,sha256=uSDLtio3ZFqdwcsMJ2O5yhjB4Q3ytbBWbA8rJREganc,10
10
- ducktools_classbuilder-0.3.0.dist-info/RECORD,,