ducktools-classbuilder 0.12.1__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.
@@ -0,0 +1,884 @@
1
+ # MIT License
2
+ #
3
+ # Copyright (c) 2024 David C Ellis
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ """
24
+ A 'prebuilt' implementation of class generation.
25
+
26
+ Includes pre and post init functions along with other methods.
27
+ """
28
+ from . import (
29
+ INTERNALS_DICT, NOTHING, FIELD_NOTHING,
30
+ Field, MethodMaker, GatheredFields, GeneratedCode, SlotMakerMeta,
31
+ builder, get_flags, get_fields,
32
+ make_unified_gatherer,
33
+ eq_maker, frozen_setattr_maker, frozen_delattr_maker, replace_maker,
34
+ get_repr_generator,
35
+ build_completed,
36
+ )
37
+
38
+ from .annotations import get_func_annotations
39
+
40
+ # These aren't used but are re-exported for ease of use
41
+ from . import SlotFields as SlotFields, KW_ONLY as KW_ONLY
42
+
43
+ PREFAB_FIELDS = "PREFAB_FIELDS"
44
+ PREFAB_INIT_FUNC = "__prefab_init__"
45
+ PRE_INIT_FUNC = "__prefab_pre_init__"
46
+ POST_INIT_FUNC = "__prefab_post_init__"
47
+
48
+
49
+ class PrefabError(Exception):
50
+ pass
51
+
52
+
53
+ def get_attributes(cls):
54
+ """
55
+ Copy of get_fields, typed to return Attribute instead of Field.
56
+ This is used in the prefab methods.
57
+
58
+ :param cls: class built with _make_prefab
59
+ :return: dict[str, Attribute] of all gathered attributes
60
+ """
61
+ return getattr(cls, INTERNALS_DICT)["fields"]
62
+
63
+
64
+ # Method Generators
65
+ def init_generator(cls, funcname="__init__"):
66
+ globs = {}
67
+ annotations = {}
68
+ # Get the internals dictionary and prepare attributes
69
+ attributes = get_attributes(cls)
70
+ flags = get_flags(cls)
71
+
72
+ kw_only = flags.get("kw_only", False)
73
+
74
+ # Handle pre/post init first - post_init can change types for __init__
75
+ # Get pre and post init arguments
76
+ pre_init_args = []
77
+ post_init_args = []
78
+ post_init_annotations = {}
79
+
80
+ for extra_funcname, func_arglist in [
81
+ (PRE_INIT_FUNC, pre_init_args),
82
+ (POST_INIT_FUNC, post_init_args),
83
+ ]:
84
+ try:
85
+ func = getattr(cls, extra_funcname)
86
+ func_code = func.__code__
87
+ except AttributeError:
88
+ pass
89
+ else:
90
+ argcount = func_code.co_argcount + func_code.co_kwonlyargcount
91
+
92
+ # Identify if method is static, if so include first arg, otherwise skip
93
+ is_static = type(cls.__dict__.get(extra_funcname)) is staticmethod
94
+
95
+ arglist = (
96
+ func_code.co_varnames[:argcount]
97
+ if is_static
98
+ else func_code.co_varnames[1:argcount]
99
+ )
100
+
101
+ func_arglist.extend(arglist)
102
+
103
+ if extra_funcname == POST_INIT_FUNC:
104
+ post_init_annotations |= get_func_annotations(func)
105
+
106
+ pos_arglist = []
107
+ kw_only_arglist = []
108
+ for name, attrib in attributes.items():
109
+ # post_init annotations can be used to broaden types.
110
+ if attrib.init:
111
+ if name in post_init_annotations:
112
+ annotations[name] = post_init_annotations[name]
113
+ elif attrib.type is not NOTHING:
114
+ annotations[name] = attrib.type
115
+
116
+ if attrib.default is not NOTHING:
117
+ if isinstance(attrib.default, (str, int, float, bool)):
118
+ # Just use the literal in these cases
119
+ arg = f"{name}={attrib.default!r}"
120
+ else:
121
+ # No guarantee repr will work for other objects
122
+ # so store the value in a variable and put it
123
+ # in the globals dict for eval
124
+ arg = f"{name}=_{name}_default"
125
+ globs[f"_{name}_default"] = attrib.default
126
+ elif attrib.default_factory is not NOTHING:
127
+ # Use NONE here and call the factory later
128
+ # This matches the behaviour of compiled
129
+ arg = f"{name}=None"
130
+ globs[f"_{name}_factory"] = attrib.default_factory
131
+ else:
132
+ arg = name
133
+ if attrib.kw_only or kw_only:
134
+ kw_only_arglist.append(arg)
135
+ else:
136
+ pos_arglist.append(arg)
137
+ # Not in init, but need to set defaults
138
+ else:
139
+ if attrib.default is not NOTHING:
140
+ globs[f"_{name}_default"] = attrib.default
141
+ elif attrib.default_factory is not NOTHING:
142
+ globs[f"_{name}_factory"] = attrib.default_factory
143
+
144
+ pos_args = ", ".join(pos_arglist)
145
+ kw_args = ", ".join(kw_only_arglist)
146
+ if pos_args and kw_args:
147
+ args = f"{pos_args}, *, {kw_args}"
148
+ elif kw_args:
149
+ args = f"*, {kw_args}"
150
+ else:
151
+ args = pos_args
152
+
153
+ assignments = []
154
+ processes = [] # post_init values still need default factories to be called.
155
+ for name, attrib in attributes.items():
156
+ if attrib.init:
157
+ if attrib.default_factory is not NOTHING:
158
+ value = f"{name} if {name} is not None else _{name}_factory()"
159
+ else:
160
+ value = name
161
+ else:
162
+ if attrib.default_factory is not NOTHING:
163
+ value = f"_{name}_factory()"
164
+ elif attrib.default is not NOTHING:
165
+ value = f"_{name}_default"
166
+ else:
167
+ value = None
168
+
169
+ if name in post_init_args:
170
+ if attrib.default_factory is not NOTHING:
171
+ processes.append((name, value))
172
+ elif value is not None:
173
+ assignments.append((name, value))
174
+
175
+ if hasattr(cls, PRE_INIT_FUNC):
176
+ pre_init_arg_call = ", ".join(f"{name}={name}" for name in pre_init_args)
177
+ pre_init_call = f" self.{PRE_INIT_FUNC}({pre_init_arg_call})\n"
178
+ else:
179
+ pre_init_call = ""
180
+
181
+ if assignments or processes:
182
+ body = ""
183
+ body += "\n".join(
184
+ f" self.{name} = {value}" for name, value in assignments
185
+ )
186
+ if assignments:
187
+ body += "\n"
188
+ body += "\n".join(f" {name} = {value}" for name, value in processes)
189
+ else:
190
+ body = " pass"
191
+
192
+ if hasattr(cls, POST_INIT_FUNC):
193
+ post_init_arg_call = ", ".join(f"{name}={name}" for name in post_init_args)
194
+ post_init_call = f" self.{POST_INIT_FUNC}({post_init_arg_call})\n"
195
+ else:
196
+ post_init_call = ""
197
+
198
+ code = (
199
+ f"def {funcname}(self, {args}):\n"
200
+ f"{pre_init_call}"
201
+ f"{body}\n"
202
+ f"{post_init_call}"
203
+ )
204
+
205
+ if annotations:
206
+ annotations["return"] = None
207
+ else:
208
+ # If there are no annotations, return an unannotated init function
209
+ annotations = None
210
+
211
+ return GeneratedCode(code, globs, annotations)
212
+
213
+
214
+ def iter_generator(cls, funcname="__iter__"):
215
+ fields = get_attributes(cls)
216
+
217
+ valid_fields = (
218
+ name for name, attrib in fields.items()
219
+ if attrib.iter
220
+ )
221
+
222
+ values = "\n".join(f" yield self.{name}" for name in valid_fields)
223
+
224
+ # if values is an empty string
225
+ if not values:
226
+ values = " yield from ()"
227
+
228
+ code = f"def {funcname}(self):\n{values}"
229
+ globs = {}
230
+ return GeneratedCode(code, globs)
231
+
232
+
233
+ def as_dict_generator(cls, funcname="as_dict"):
234
+ fields = get_attributes(cls)
235
+
236
+ vals = ", ".join(
237
+ f"'{name}': self.{name}"
238
+ for name, attrib in fields.items()
239
+ if attrib.serialize
240
+ )
241
+ out_dict = f"{{{vals}}}"
242
+ code = f"def {funcname}(self): return {out_dict}\n"
243
+
244
+ globs = {}
245
+ return GeneratedCode(code, globs)
246
+
247
+
248
+ def hash_generator(cls, funcname="__hash__"):
249
+ fields = get_attributes(cls)
250
+ vals = ", ".join(
251
+ f"self.{name}"
252
+ for name, attrib in fields.items()
253
+ if attrib.compare
254
+ )
255
+ code = f"def {funcname}(self): return hash(({vals}))\n"
256
+ globs = {}
257
+ return GeneratedCode(code, globs)
258
+
259
+
260
+ init_maker = MethodMaker("__init__", init_generator)
261
+ prefab_init_maker = MethodMaker(PREFAB_INIT_FUNC, init_generator)
262
+ repr_maker = MethodMaker(
263
+ "__repr__",
264
+ get_repr_generator(recursion_safe=False, eval_safe=True)
265
+ )
266
+ recursive_repr_maker = MethodMaker(
267
+ "__repr__",
268
+ get_repr_generator(recursion_safe=True, eval_safe=True)
269
+ )
270
+ iter_maker = MethodMaker("__iter__", iter_generator)
271
+ asdict_maker = MethodMaker("as_dict", as_dict_generator)
272
+ hash_maker = MethodMaker("__hash__", hash_generator)
273
+
274
+
275
+ # Updated field with additional attributes
276
+ class Attribute(Field, ignore_annotations=True):
277
+ """
278
+ Get an object to define a prefab attribute
279
+
280
+ :param default: Default value for this attribute
281
+ :param default_factory: 0 argument callable to give a default value
282
+ (for otherwise mutable defaults, eg: list)
283
+ :param init: Include this attribute in the __init__ parameters
284
+ :param repr: Include this attribute in the class __repr__
285
+ :param compare: Include this attribute in the class __eq__
286
+ :param iter: Include this attribute in the class __iter__ if generated
287
+ :param kw_only: Make this argument keyword only in init
288
+ :param serialize: Include this attribute in methods that serialize to dict
289
+ :param doc: Parameter documentation for slotted classes
290
+ :param metadata: Additional non-construction related metadata
291
+ :param type: Type of this attribute (for slotted classes)
292
+ """
293
+ iter: bool = Field(default=True) # type: ignore
294
+ serialize: bool = Field(default=True) # type: ignore
295
+ metadata: dict = Field(default=FIELD_NOTHING, default_factory=dict) # type: ignore
296
+
297
+
298
+ # noinspection PyShadowingBuiltins
299
+ def attribute(
300
+ *,
301
+ default=NOTHING,
302
+ default_factory=NOTHING,
303
+ init=True,
304
+ repr=True,
305
+ compare=True,
306
+ iter=True,
307
+ kw_only=False,
308
+ serialize=True,
309
+ exclude_field=False,
310
+ private=False,
311
+ doc=None,
312
+ metadata=None,
313
+ type=NOTHING,
314
+ ):
315
+ """
316
+ Helper function to get an object to define a prefab Attribute
317
+
318
+ :param default: Default value for this attribute
319
+ :param default_factory: 0 argument callable to give a default value
320
+ (for otherwise mutable defaults, eg: list)
321
+ :param init: Include this attribute in the __init__ parameters
322
+ :param repr: Include this attribute in the class __repr__
323
+ :param compare: Include this attribute in the class __eq__
324
+ :param iter: Include this attribute in the class __iter__ if generated
325
+ :param kw_only: Make this argument keyword only in init
326
+ :param serialize: Include this attribute in methods that serialize to dict
327
+ :param exclude_field: Shorthand for setting repr, compare, iter and serialize to False
328
+ :param private: Short for init, repr, compare, iter, serialize = False, must have default or factory
329
+ :param doc: Parameter documentation for slotted classes
330
+ :param metadata: Dictionary for additional non-construction metadata
331
+ :param type: Type of this attribute
332
+
333
+ :return: Attribute generated with these parameters.
334
+ """
335
+ if exclude_field:
336
+ repr = False
337
+ compare = False
338
+ iter = False
339
+ serialize = False
340
+
341
+ if private:
342
+ if default is NOTHING and default_factory is NOTHING:
343
+ raise AttributeError("Private attributes must have defaults or factories.")
344
+ init = False
345
+ repr = False
346
+ compare = False
347
+ iter = False
348
+ serialize = False
349
+
350
+ return Attribute(
351
+ default=default,
352
+ default_factory=default_factory,
353
+ init=init,
354
+ repr=repr,
355
+ compare=compare,
356
+ iter=iter,
357
+ kw_only=kw_only,
358
+ serialize=serialize,
359
+ doc=doc,
360
+ type=type,
361
+ metadata=metadata,
362
+ )
363
+
364
+
365
+ prefab_gatherer = make_unified_gatherer(
366
+ Attribute,
367
+ leave_default_values=False,
368
+ )
369
+
370
+
371
+ # Class Builders
372
+ # noinspection PyShadowingBuiltins
373
+ def _make_prefab(
374
+ cls,
375
+ *,
376
+ init=True,
377
+ repr=True,
378
+ eq=True,
379
+ iter=False,
380
+ match_args=True,
381
+ kw_only=False,
382
+ frozen=False,
383
+ replace=True,
384
+ dict_method=False,
385
+ recursive_repr=False,
386
+ gathered_fields=None,
387
+ ignore_annotations=False,
388
+ ):
389
+ """
390
+ Generate boilerplate code for dunder methods in a class.
391
+
392
+ :param cls: Class to convert to a prefab
393
+ :param init: generate __init__
394
+ :param repr: generate __repr__
395
+ :param eq: generate __eq__
396
+ :param iter: generate __iter__
397
+ :param match_args: generate __match_args__
398
+ :param kw_only: Make all attributes keyword only
399
+ :param frozen: Prevent attribute values from being changed once defined
400
+ (This does not prevent the modification of mutable attributes
401
+ such as lists)
402
+ :param replace: Add a generated __replace__ method
403
+ :param dict_method: Include an as_dict method for faster dictionary creation
404
+ :param recursive_repr: Safely handle repr in case of recursion
405
+ :param gathered_fields: Pre-gathered fields callable, to skip re-collecting attributes
406
+ :param ignore_annotations: Ignore annotated fields and only look at `attribute` fields
407
+ :return: class with __ methods defined
408
+ """
409
+ cls_dict = cls.__dict__
410
+
411
+ if build_completed(cls_dict):
412
+ raise PrefabError(
413
+ f"Decorated class {cls.__name__!r} "
414
+ f"has already been processed as a Prefab."
415
+ )
416
+
417
+ if not frozen:
418
+ # If the class is not frozen, make sure it doesn't inherit
419
+ # from a frozen class
420
+ for base in cls.__mro__[1:-1]: # Exclude this class and object
421
+ try:
422
+ fields = get_flags(base)
423
+ except (TypeError, KeyError):
424
+ continue
425
+ else:
426
+ if fields.get("frozen") is True:
427
+ raise TypeError("Cannot inherit non-frozen prefab from a frozen one")
428
+
429
+ slots = cls_dict.get("__slots__")
430
+ slotted = False if slots is None else True
431
+
432
+ if gathered_fields is None:
433
+ gatherer = prefab_gatherer
434
+ else:
435
+ gatherer = gathered_fields
436
+
437
+ methods = set()
438
+
439
+ if init and "__init__" not in cls_dict:
440
+ methods.add(init_maker)
441
+ else:
442
+ methods.add(prefab_init_maker)
443
+
444
+ if repr and "__repr__" not in cls_dict:
445
+ if recursive_repr:
446
+ methods.add(recursive_repr_maker)
447
+ else:
448
+ methods.add(repr_maker)
449
+ if eq and "__eq__" not in cls_dict:
450
+ methods.add(eq_maker)
451
+ if iter and "__iter__" not in cls_dict:
452
+ methods.add(iter_maker)
453
+ if frozen:
454
+ # Check __setattr__ and __delattr__ are not already defined on this class
455
+ if "__setattr__" in cls_dict:
456
+ raise TypeError("Cannot overwrite '__setattr__' method that already exists")
457
+ elif "__delattr__" in cls_dict:
458
+ raise TypeError("Cannot overwrite '__delattr__' method that already exists")
459
+ methods.add(frozen_setattr_maker)
460
+ methods.add(frozen_delattr_maker)
461
+ if "__hash__" not in cls_dict: # it's ok if the user has defined __hash__ already
462
+ methods.add(hash_maker)
463
+ if dict_method:
464
+ methods.add(asdict_maker)
465
+
466
+ if replace and "__replace__" not in cls_dict:
467
+ methods.add(replace_maker)
468
+
469
+ flags = {
470
+ "slotted": slotted,
471
+ "init": init,
472
+ "repr": repr,
473
+ "eq": eq,
474
+ "iter": iter,
475
+ "match_args": match_args,
476
+ "kw_only": kw_only,
477
+ "frozen": frozen,
478
+ "replace": replace,
479
+ "dict_method": dict_method,
480
+ "recursive_repr": recursive_repr,
481
+ "ignore_annotations": ignore_annotations,
482
+ }
483
+
484
+ cls = builder(
485
+ cls,
486
+ gatherer=gatherer,
487
+ methods=methods,
488
+ flags=flags,
489
+ )
490
+
491
+ # Get fields now the class has been built
492
+ fields = get_fields(cls)
493
+
494
+ # Check pre_init and post_init functions if they exist
495
+ try:
496
+ func = getattr(cls, PRE_INIT_FUNC)
497
+ func_code = func.__code__
498
+ except AttributeError:
499
+ pass
500
+ else:
501
+ if func_code.co_posonlyargcount > 0:
502
+ raise PrefabError(
503
+ "Positional only arguments are not supported in pre or post init functions."
504
+ )
505
+
506
+ argcount = func_code.co_argcount + func_code.co_kwonlyargcount
507
+
508
+ # Include the first argument if the method is static
509
+ is_static = type(cls.__dict__.get(PRE_INIT_FUNC)) is staticmethod
510
+
511
+ arglist = (
512
+ func_code.co_varnames[:argcount]
513
+ if is_static
514
+ else func_code.co_varnames[1:argcount]
515
+ )
516
+
517
+ for item in arglist:
518
+ if item not in fields.keys():
519
+ raise PrefabError(
520
+ f"{item} argument in {PRE_INIT_FUNC} is not a valid attribute."
521
+ )
522
+
523
+ post_init_args = []
524
+ try:
525
+ func = getattr(cls, POST_INIT_FUNC)
526
+ func_code = func.__code__
527
+ except AttributeError:
528
+ pass
529
+ else:
530
+ if func_code.co_posonlyargcount > 0:
531
+ raise PrefabError(
532
+ "Positional only arguments are not supported in pre or post init functions."
533
+ )
534
+
535
+ argcount = func_code.co_argcount + func_code.co_kwonlyargcount
536
+
537
+ # Include the first argument if the method is static
538
+ is_static = type(cls.__dict__.get(POST_INIT_FUNC)) is staticmethod
539
+
540
+ arglist = (
541
+ func_code.co_varnames[:argcount]
542
+ if is_static
543
+ else func_code.co_varnames[1:argcount]
544
+ )
545
+
546
+ for item in arglist:
547
+ if item not in fields.keys():
548
+ raise PrefabError(
549
+ f"{item} argument in {POST_INIT_FUNC} is not a valid attribute."
550
+ )
551
+
552
+ post_init_args.extend(arglist)
553
+
554
+ # Gather values for match_args and do some syntax checking
555
+ default_defined = []
556
+ valid_args = list(fields.keys())
557
+
558
+ for name, attrib in fields.items():
559
+ # slot_gather and parent classes may use Fields
560
+ # prefabs require Attributes, so convert.
561
+ if not isinstance(attrib, Attribute):
562
+ attrib = Attribute.from_field(attrib)
563
+ fields[name] = attrib
564
+
565
+ if not kw_only:
566
+ # Syntax check arguments for __init__ don't have non-default after default
567
+ if attrib.init and not attrib.kw_only:
568
+ if attrib.default is not NOTHING or attrib.default_factory is not NOTHING:
569
+ default_defined.append(name)
570
+ else:
571
+ if default_defined:
572
+ names = ", ".join(default_defined)
573
+ raise SyntaxError(
574
+ "non-default argument follows default argument",
575
+ f"defaults: {names}",
576
+ f"non_default after default: {name}",
577
+ )
578
+
579
+ setattr(cls, PREFAB_FIELDS, valid_args)
580
+
581
+ if match_args and "__match_args__" not in cls_dict:
582
+ setattr(cls, "__match_args__", tuple(valid_args))
583
+
584
+ return cls
585
+
586
+
587
+ class Prefab(metaclass=SlotMakerMeta, gatherer=prefab_gatherer):
588
+ __slots__ = {} # type: ignore
589
+
590
+ def __init_subclass__(
591
+ cls,
592
+ **kwargs
593
+ ):
594
+ """
595
+ Generate boilerplate code for dunder methods in a class.
596
+
597
+ Use as a base class, slotted by default
598
+
599
+ :param init: generates __init__ if true or __prefab_init__ if false
600
+ :param repr: generate __repr__
601
+ :param eq: generate __eq__
602
+ :param iter: generate __iter__
603
+ :param match_args: generate __match_args__
604
+ :param kw_only: make all attributes keyword only
605
+ :param frozen: Prevent attribute values from being changed once defined
606
+ (This does not prevent the modification of mutable attributes such as lists)
607
+ :param replace: generate a __replace__ method
608
+ :param dict_method: Include an as_dict method for faster dictionary creation
609
+ :param recursive_repr: Safely handle repr in case of recursion
610
+ :param ignore_annotations: Ignore type annotations when gathering fields, only look for
611
+ slots or attribute(...) values
612
+ :param slots: automatically generate slots for this class's attributes
613
+ """
614
+ default_values = {
615
+ "init": True,
616
+ "repr": True,
617
+ "eq": True,
618
+ "iter": False,
619
+ "match_args": True,
620
+ "kw_only": False,
621
+ "frozen": False,
622
+ "replace": True,
623
+ "dict_method": False,
624
+ "recursive_repr": False,
625
+ }
626
+
627
+ try:
628
+ flags = get_flags(cls).copy()
629
+ except (TypeError, KeyError):
630
+ flags = {}
631
+ else:
632
+ # Remove the value of slotted if it exists
633
+ flags.pop("slotted", None)
634
+
635
+ for k in default_values:
636
+ kwarg_value = kwargs.pop(k, None)
637
+ default = default_values[k]
638
+
639
+ if kwarg_value is not None:
640
+ flags[k] = kwarg_value
641
+ elif flags.get(k) is None:
642
+ flags[k] = default
643
+
644
+ if kwargs:
645
+ error_args = ", ".join(repr(k) for k in kwargs)
646
+ raise TypeError(
647
+ f"Prefab.__init_subclass__ got unexpected keyword arguments {error_args}"
648
+ )
649
+
650
+ _make_prefab(
651
+ cls,
652
+ **flags
653
+ )
654
+
655
+
656
+ # noinspection PyShadowingBuiltins
657
+ def prefab(
658
+ cls=None,
659
+ *,
660
+ init=True,
661
+ repr=True,
662
+ eq=True,
663
+ iter=False,
664
+ match_args=True,
665
+ kw_only=False,
666
+ frozen=False,
667
+ replace=True,
668
+ dict_method=False,
669
+ recursive_repr=False,
670
+ ignore_annotations=False,
671
+ ):
672
+ """
673
+ Generate boilerplate code for dunder methods in a class.
674
+
675
+ Use as a decorator.
676
+
677
+ :param cls: Class to convert to a prefab
678
+ :param init: generates __init__ if true or __prefab_init__ if false
679
+ :param repr: generate __repr__
680
+ :param eq: generate __eq__
681
+ :param iter: generate __iter__
682
+ :param match_args: generate __match_args__
683
+ :param kw_only: make all attributes keyword only
684
+ :param frozen: Prevent attribute values from being changed once defined
685
+ (This does not prevent the modification of mutable attributes such as lists)
686
+ :param replace: generate a __replace__ method
687
+ :param dict_method: Include an as_dict method for faster dictionary creation
688
+ :param recursive_repr: Safely handle repr in case of recursion
689
+ :param ignore_annotations: Ignore type annotations when gathering fields, only look for
690
+ slots or attribute(...) values
691
+
692
+ :return: class with __ methods defined
693
+ """
694
+ if not cls:
695
+ # Called as () method to change defaults
696
+ return lambda cls_: prefab(
697
+ cls_,
698
+ init=init,
699
+ repr=repr,
700
+ eq=eq,
701
+ iter=iter,
702
+ match_args=match_args,
703
+ kw_only=kw_only,
704
+ frozen=frozen,
705
+ replace=replace,
706
+ dict_method=dict_method,
707
+ recursive_repr=recursive_repr,
708
+ ignore_annotations=ignore_annotations,
709
+ )
710
+ else:
711
+ return _make_prefab(
712
+ cls,
713
+ init=init,
714
+ repr=repr,
715
+ eq=eq,
716
+ iter=iter,
717
+ match_args=match_args,
718
+ kw_only=kw_only,
719
+ frozen=frozen,
720
+ replace=replace,
721
+ dict_method=dict_method,
722
+ recursive_repr=recursive_repr,
723
+ ignore_annotations=ignore_annotations,
724
+ )
725
+
726
+
727
+ # noinspection PyShadowingBuiltins
728
+ def build_prefab(
729
+ class_name,
730
+ attributes,
731
+ *,
732
+ bases=(),
733
+ class_dict=None,
734
+ init=True,
735
+ repr=True,
736
+ eq=True,
737
+ iter=False,
738
+ match_args=True,
739
+ kw_only=False,
740
+ frozen=False,
741
+ replace=True,
742
+ dict_method=False,
743
+ recursive_repr=False,
744
+ slots=False,
745
+ ):
746
+ """
747
+ Dynamically construct a (dynamic) prefab.
748
+
749
+ :param class_name: name of the resulting prefab class
750
+ :param attributes: list of (name, attribute()) pairs to assign to the class
751
+ for construction
752
+ :param bases: Base classes to inherit from
753
+ :param class_dict: Other values to add to the class dictionary on creation
754
+ This is the 'dict' parameter from 'type'
755
+ :param init: generates __init__ if true or __prefab_init__ if false
756
+ :param repr: generate __repr__
757
+ :param eq: generate __eq__
758
+ :param iter: generate __iter__
759
+ :param match_args: generate __match_args__
760
+ :param kw_only: make all attributes keyword only
761
+ :param frozen: Prevent attribute values from being changed once defined
762
+ (This does not prevent the modification of mutable attributes such as lists)
763
+ :param replace: generate a __replace__ method
764
+ :param dict_method: Include an as_dict method for faster dictionary creation
765
+ :param recursive_repr: Safely handle repr in case of recursion
766
+ :param slots: Make the resulting class slotted
767
+ :return: class with __ methods defined
768
+ """
769
+ class_dict = {} if class_dict is None else class_dict.copy()
770
+
771
+ class_annotations = {}
772
+ class_slots = {}
773
+ fields = {}
774
+
775
+ for name, attrib in attributes:
776
+ if isinstance(attrib, Attribute):
777
+ fields[name] = attrib
778
+ elif isinstance(attrib, Field):
779
+ fields[name] = Attribute.from_field(attrib)
780
+ else:
781
+ fields[name] = Attribute(default=attrib)
782
+
783
+ if attrib.type is not NOTHING:
784
+ class_annotations[name] = attrib.type
785
+
786
+ class_slots[name] = attrib.doc
787
+
788
+ if slots:
789
+ class_dict["__slots__"] = class_slots
790
+
791
+ class_dict["__annotations__"] = class_annotations
792
+
793
+ cls = type(class_name, bases, class_dict)
794
+
795
+ gathered_fields = GatheredFields(fields, {})
796
+
797
+ cls = _make_prefab(
798
+ cls,
799
+ init=init,
800
+ repr=repr,
801
+ eq=eq,
802
+ iter=iter,
803
+ match_args=match_args,
804
+ kw_only=kw_only,
805
+ frozen=frozen,
806
+ replace=replace,
807
+ dict_method=dict_method,
808
+ recursive_repr=recursive_repr,
809
+ gathered_fields=gathered_fields,
810
+ )
811
+
812
+ return cls
813
+
814
+
815
+ # Extra Functions
816
+ def is_prefab(o):
817
+ """
818
+ Identifier function, return True if an object is a prefab class *or* if
819
+ it is an instance of a prefab class.
820
+
821
+ The check works by looking for a PREFAB_FIELDS attribute.
822
+
823
+ :param o: object for comparison
824
+ :return: True/False
825
+ """
826
+ cls = o if isinstance(o, type) else type(o)
827
+ return hasattr(cls, PREFAB_FIELDS)
828
+
829
+
830
+ def is_prefab_instance(o):
831
+ """
832
+ Identifier function, return True if an object is an instance of a prefab
833
+ class.
834
+
835
+ The check works by looking for a PREFAB_FIELDS attribute.
836
+
837
+ :param o: object for comparison
838
+ :return: True/False
839
+ """
840
+ return hasattr(type(o), PREFAB_FIELDS)
841
+
842
+
843
+ def as_dict(o):
844
+ """
845
+ Get the valid fields from a prefab respecting the serialize
846
+ values of attributes
847
+
848
+ :param o: instance of a prefab class
849
+ :return: dictionary of {k: v} from fields
850
+ """
851
+ cls = type(o)
852
+ if not hasattr(cls, PREFAB_FIELDS):
853
+ raise TypeError(f"{o!r} should be a prefab instance, not {cls}")
854
+
855
+ # Attempt to use the generated method if available
856
+ try:
857
+ return o.as_dict()
858
+ except AttributeError:
859
+ pass
860
+
861
+ flds = get_attributes(cls)
862
+
863
+ return {
864
+ name: getattr(o, name)
865
+ for name, attrib in flds.items()
866
+ if attrib.serialize
867
+ }
868
+
869
+
870
+ def replace(obj, /, **changes):
871
+ """
872
+ Create a copy of a prefab instance with values provided to 'changes' replaced
873
+
874
+ :param obj: prefab instance
875
+ :return: new prefab instance
876
+ """
877
+ if not is_prefab_instance(obj):
878
+ raise TypeError("replace() should be called on prefab instances")
879
+ try:
880
+ replace_func = obj.__replace__
881
+ except AttributeError:
882
+ raise TypeError(f"{obj.__class__.__name__!r} does not support __replace__")
883
+
884
+ return replace_func(**changes)