ducktools-classbuilder 0.5.1__py3-none-any.whl → 0.6.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.

Potentially problematic release.


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

@@ -19,18 +19,39 @@
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
+
23
+ # In this module there are some internal bits of circular logic.
24
+ #
25
+ # 'Field' needs to exist in order to be used in gatherers, but is itself a
26
+ # partially constructed class. These constructed attributes are placed on
27
+ # 'Field' post construction.
28
+ #
29
+ # The 'SlotMakerMeta' metaclass generates 'Field' instances to go in __slots__
30
+ # but is also the metaclass used to construct 'Field'.
31
+ # Field itself sidesteps this by defining __slots__ to avoid that branch.
32
+
22
33
  import sys
23
34
 
24
- __version__ = "v0.5.1"
35
+ from .annotations import get_ns_annotations, is_classvar
36
+
37
+ __version__ = "v0.6.1"
25
38
 
26
39
  # Change this name if you make heavy modifications
27
40
  INTERNALS_DICT = "__classbuilder_internals__"
41
+ META_GATHERER_NAME = "_meta_gatherer"
28
42
 
29
43
 
30
44
  # If testing, make Field classes frozen to make sure attributes are not
31
45
  # overwritten. When running this is a performance penalty so it is not required.
32
46
  _UNDER_TESTING = "pytest" in sys.modules
33
47
 
48
+ # Obtain types the same way types.py does in pypy
49
+ # See: https://github.com/pypy/pypy/blob/19d9fa6be11165116dd0839b9144d969ab426ae7/lib-python/3/types.py#L61-L73
50
+ class _C: __slots__ = 's' # noqa
51
+ _MemberDescriptorType = type(_C.s) # noqa
52
+ _MappingProxyType = type(type.__dict__)
53
+ del _C
54
+
34
55
 
35
56
  def get_fields(cls, *, local=False):
36
57
  """
@@ -56,6 +77,17 @@ def get_flags(cls):
56
77
  return getattr(cls, INTERNALS_DICT)["flags"]
57
78
 
58
79
 
80
+ def get_methods(cls):
81
+ """
82
+ Utility function to gather the set of methods
83
+ from the class internals.
84
+
85
+ :param cls: generated class
86
+ :return: dict of generated methods attached to the class by name
87
+ """
88
+ return getattr(cls, INTERNALS_DICT)["methods"]
89
+
90
+
59
91
  def _get_inst_fields(inst):
60
92
  # This is an internal helper for constructing new
61
93
  # 'Field' instances from existing ones.
@@ -75,6 +107,38 @@ class _NothingType:
75
107
  NOTHING = _NothingType()
76
108
 
77
109
 
110
+ # KW_ONLY sentinel 'type' to use to indicate all subsequent attributes are
111
+ # keyword only
112
+ # noinspection PyPep8Naming
113
+ class _KW_ONLY_TYPE:
114
+ def __repr__(self):
115
+ return "<KW_ONLY Sentinel Object>"
116
+
117
+
118
+ KW_ONLY = _KW_ONLY_TYPE()
119
+
120
+
121
+ class GeneratedCode:
122
+ """
123
+ This class provides a return value for the generated output from source code
124
+ generators.
125
+ """
126
+ __slots__ = ("source_code", "globs")
127
+
128
+ def __init__(self, source_code, globs):
129
+ self.source_code = source_code
130
+ self.globs = globs
131
+
132
+ def __repr__(self):
133
+ first_source_line = self.source_code.split("\n")[0]
134
+ return f"GeneratorOutput(source_code='{first_source_line} ...', globs={self.globs!r})"
135
+
136
+ def __eq__(self, other):
137
+ if self.__class__ is other.__class__:
138
+ return (self.source_code, self.globs) == (other.source_code, other.globs)
139
+ return NotImplemented
140
+
141
+
78
142
  class MethodMaker:
79
143
  """
80
144
  The descriptor class to place where methods should be generated.
@@ -94,53 +158,88 @@ class MethodMaker:
94
158
  def __repr__(self):
95
159
  return f"<MethodMaker for {self.funcname!r} method>"
96
160
 
97
- def __get__(self, instance, cls):
161
+ def __get__(self, obj, objtype=None):
162
+ if objtype is None or issubclass(objtype, type):
163
+ # Called with get(ourclass, type(ourclass))
164
+ cls = obj
165
+ else:
166
+ # Called with get(inst | None, ourclass)
167
+ cls = objtype
168
+
98
169
  local_vars = {}
99
- code, globs = self.code_generator(cls)
100
- exec(code, globs, local_vars)
170
+ gen = self.code_generator(cls, self.funcname)
171
+ exec(gen.source_code, gen.globs, local_vars)
101
172
  method = local_vars.get(self.funcname)
102
- method.__qualname__ = f"{cls.__qualname__}.{self.funcname}"
173
+
174
+ try:
175
+ method.__qualname__ = f"{cls.__qualname__}.{self.funcname}"
176
+ except AttributeError:
177
+ # This might be a property or some other special
178
+ # descriptor. Don't try to rename.
179
+ pass
103
180
 
104
181
  # Replace this descriptor on the class with the generated function
105
182
  setattr(cls, self.funcname, method)
106
183
 
107
184
  # Use 'get' to return the generated function as a bound method
108
185
  # instead of as a regular function for first usage.
109
- return method.__get__(instance, cls)
186
+ return method.__get__(obj, objtype)
110
187
 
111
188
 
112
189
  def get_init_generator(null=NOTHING, extra_code=None):
113
- def cls_init_maker(cls):
190
+ def cls_init_maker(cls, funcname="__init__"):
114
191
  fields = get_fields(cls)
115
192
  flags = get_flags(cls)
116
193
 
117
194
  arglist = []
195
+ kw_only_arglist = []
118
196
  assignments = []
119
197
  globs = {}
120
198
 
121
- if flags.get("kw_only", False):
122
- arglist.append("*")
199
+ kw_only_flag = flags.get("kw_only", False)
123
200
 
124
201
  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}"
202
+ if v.init:
203
+ if v.default is not null:
204
+ globs[f"_{k}_default"] = v.default
205
+ arg = f"{k}=_{k}_default"
206
+ assignment = f"self.{k} = {k}"
207
+ elif v.default_factory is not null:
208
+ globs[f"_{k}_factory"] = v.default_factory
209
+ arg = f"{k}=None"
210
+ assignment = f"self.{k} = _{k}_factory() if {k} is None else {k}"
211
+ else:
212
+ arg = f"{k}"
213
+ assignment = f"self.{k} = {k}"
214
+
215
+ if kw_only_flag or v.kw_only:
216
+ kw_only_arglist.append(arg)
217
+ else:
218
+ arglist.append(arg)
136
219
 
137
- arglist.append(arg)
138
- assignments.append(assignment)
220
+ assignments.append(assignment)
221
+ else:
222
+ if v.default is not null:
223
+ globs[f"_{k}_default"] = v.default
224
+ assignment = f"self.{k} = _{k}_default"
225
+ assignments.append(assignment)
226
+ elif v.default_factory is not null:
227
+ globs[f"_{k}_factory"] = v.default_factory
228
+ assignment = f"self.{k} = _{k}_factory()"
229
+ assignments.append(assignment)
230
+
231
+ pos_args = ", ".join(arglist)
232
+ kw_args = ", ".join(kw_only_arglist)
233
+ if pos_args and kw_args:
234
+ args = f"{pos_args}, *, {kw_args}"
235
+ elif kw_args:
236
+ args = f"*, {kw_args}"
237
+ else:
238
+ args = pos_args
139
239
 
140
- args = ", ".join(arglist)
141
240
  assigns = "\n ".join(assignments) if assignments else "pass\n"
142
241
  code = (
143
- f"def __init__(self, {args}):\n"
242
+ f"def {funcname}(self, {args}):\n"
144
243
  f" {assigns}\n"
145
244
  )
146
245
  # Handle additional function calls
@@ -149,7 +248,7 @@ def get_init_generator(null=NOTHING, extra_code=None):
149
248
  for line in extra_code:
150
249
  code += f" {line}\n"
151
250
 
152
- return code, globs
251
+ return GeneratedCode(code, globs)
153
252
 
154
253
  return cls_init_maker
155
254
 
@@ -157,23 +256,75 @@ def get_init_generator(null=NOTHING, extra_code=None):
157
256
  init_generator = get_init_generator()
158
257
 
159
258
 
160
- def repr_generator(cls):
161
- fields = get_fields(cls)
162
- content = ", ".join(
163
- f"{name}={{self.{name}!r}}"
164
- for name, attrib in fields.items()
165
- )
166
- code = (
167
- f"def __repr__(self):\n"
168
- f" return f'{{type(self).__qualname__}}({content})'\n"
169
- )
170
- globs = {}
171
- return code, globs
259
+ def get_repr_generator(recursion_safe=False, eval_safe=False):
260
+ """
261
+
262
+ :param recursion_safe: use reprlib.recursive_repr
263
+ :param eval_safe: if the repr is known not to eval correctly,
264
+ generate a repr which will intentionally
265
+ not evaluate.
266
+ :return:
267
+ """
268
+ def cls_repr_generator(cls, funcname="__repr__"):
269
+ fields = get_fields(cls)
270
+
271
+ globs = {}
272
+ will_eval = True
273
+ valid_names = []
274
+
275
+ for name, fld in fields.items():
276
+ if fld.repr:
277
+ valid_names.append(name)
278
+
279
+ if will_eval and (fld.init ^ fld.repr):
280
+ will_eval = False
281
+
282
+ content = ", ".join(
283
+ f"{name}={{self.{name}!r}}"
284
+ for name in valid_names
285
+ )
286
+
287
+ if recursion_safe:
288
+ import reprlib
289
+ globs["_recursive_repr"] = reprlib.recursive_repr()
290
+ recursion_func = "@_recursive_repr\n"
291
+ else:
292
+ recursion_func = ""
293
+
294
+ if eval_safe and will_eval is False:
295
+ if content:
296
+ code = (
297
+ f"{recursion_func}"
298
+ f"def {funcname}(self):\n"
299
+ f" return f'<generated class {{type(self).__qualname__}}; {content}>'\n"
300
+ )
301
+ else:
302
+ code = (
303
+ f"{recursion_func}"
304
+ f"def {funcname}(self):\n"
305
+ f" return f'<generated class {{type(self).__qualname__}}>'\n"
306
+ )
307
+ else:
308
+ code = (
309
+ f"{recursion_func}"
310
+ f"def {funcname}(self):\n"
311
+ f" return f'{{type(self).__qualname__}}({content})'\n"
312
+ )
313
+
314
+ return GeneratedCode(code, globs)
315
+ return cls_repr_generator
316
+
172
317
 
318
+ repr_generator = get_repr_generator()
173
319
 
174
- def eq_generator(cls):
320
+
321
+ def eq_generator(cls, funcname="__eq__"):
175
322
  class_comparison = "self.__class__ is other.__class__"
176
- field_names = get_fields(cls)
323
+ field_names = [
324
+ name
325
+ for name, attrib in get_fields(cls).items()
326
+ if attrib.compare
327
+ ]
177
328
 
178
329
  if field_names:
179
330
  selfvals = ",".join(f"self.{name}" for name in field_names)
@@ -183,15 +334,15 @@ def eq_generator(cls):
183
334
  instance_comparison = "True"
184
335
 
185
336
  code = (
186
- f"def __eq__(self, other):\n"
337
+ f"def {funcname}(self, other):\n"
187
338
  f" return {instance_comparison} if {class_comparison} else NotImplemented\n"
188
339
  )
189
340
  globs = {}
190
341
 
191
- return code, globs
342
+ return GeneratedCode(code, globs)
192
343
 
193
344
 
194
- def frozen_setattr_generator(cls):
345
+ def frozen_setattr_generator(cls, funcname="__setattr__"):
195
346
  globs = {}
196
347
  field_names = set(get_fields(cls))
197
348
  flags = get_flags(cls)
@@ -215,21 +366,21 @@ def frozen_setattr_generator(cls):
215
366
  f" else:\n"
216
367
  f" {setattr_method}\n"
217
368
  )
218
- code = f"def __setattr__(self, name, value):\n{body}"
369
+ code = f"def {funcname}(self, name, value):\n{body}"
219
370
 
220
- return code, globs
371
+ return GeneratedCode(code, globs)
221
372
 
222
373
 
223
- def frozen_delattr_generator(cls):
374
+ def frozen_delattr_generator(cls, funcname="__delattr__"):
224
375
  body = (
225
376
  ' raise TypeError(\n'
226
377
  ' f"{type(self).__name__!r} object "\n'
227
378
  ' f"does not support attribute deletion"\n'
228
379
  ' )\n'
229
380
  )
230
- code = f"def __delattr__(self, name):\n{body}"
381
+ code = f"def {funcname}(self, name):\n{body}"
231
382
  globs = {}
232
- return code, globs
383
+ return GeneratedCode(code, globs)
233
384
 
234
385
 
235
386
  # As only the __get__ method refers to the class we can use the same
@@ -241,6 +392,15 @@ frozen_setattr_maker = MethodMaker("__setattr__", frozen_setattr_generator)
241
392
  frozen_delattr_maker = MethodMaker("__delattr__", frozen_delattr_generator)
242
393
  default_methods = frozenset({init_maker, repr_maker, eq_maker})
243
394
 
395
+ # Special `__init__` maker for 'Field' subclasses
396
+ _field_init_maker = MethodMaker(
397
+ funcname="__init__",
398
+ code_generator=get_init_generator(
399
+ null=_NothingType(),
400
+ extra_code=["self.validate_field()"],
401
+ )
402
+ )
403
+
244
404
 
245
405
  def builder(cls=None, /, *, gatherer, methods, flags=None):
246
406
  """
@@ -292,16 +452,77 @@ def builder(cls=None, /, *, gatherer, methods, flags=None):
292
452
  internals["flags"] = flags if flags is not None else {}
293
453
 
294
454
  # Assign all of the method generators
455
+ internal_methods = {}
295
456
  for method in methods:
296
457
  setattr(cls, method.funcname, method)
458
+ internal_methods[method.funcname] = method
459
+
460
+ internals["methods"] = _MappingProxyType(internal_methods)
297
461
 
298
462
  return cls
299
463
 
300
464
 
465
+ # Slot gathering tools
466
+ # Subclass of dict to be identifiable by isinstance checks
467
+ # For anything more complicated this could be made into a Mapping
468
+ class SlotFields(dict):
469
+ """
470
+ A plain dict subclass.
471
+
472
+ For declaring slotfields there are no additional features required
473
+ other than recognising that this is intended to be used as a class
474
+ generating dict and isn't a regular dictionary that ended up in
475
+ `__slots__`.
476
+
477
+ This should be replaced on `__slots__` after fields have been gathered.
478
+ """
479
+ def __repr__(self):
480
+ return f"SlotFields({super().__repr__()})"
481
+
482
+
483
+ # Tool to convert annotations to slots as a metaclass
484
+ class SlotMakerMeta(type):
485
+ """
486
+ Metaclass to convert annotations or Field(...) attributes to slots.
487
+
488
+ Will not convert `ClassVar` hinted values.
489
+ """
490
+ def __new__(cls, name, bases, ns, slots=True, **kwargs):
491
+ # This should only run if slots=True is declared
492
+ # and __slots__ have not already been defined
493
+ if slots and "__slots__" not in ns:
494
+ # Check if a different gatherer has been set in any base classes
495
+ # Default to unified gatherer
496
+ gatherer = ns.get(META_GATHERER_NAME, None)
497
+ if not gatherer:
498
+ for base in bases:
499
+ if g := getattr(base, META_GATHERER_NAME, None):
500
+ gatherer = g
501
+ break
502
+
503
+ if not gatherer:
504
+ gatherer = unified_gatherer
505
+
506
+ # Obtain slots from annotations or attributes
507
+ cls_fields, cls_modifications = gatherer(ns)
508
+ for k, v in cls_modifications.items():
509
+ if v is NOTHING:
510
+ ns.pop(k)
511
+ else:
512
+ ns[k] = v
513
+
514
+ # Place slots *after* everything else to be safe
515
+ ns["__slots__"] = SlotFields(cls_fields)
516
+
517
+ new_cls = super().__new__(cls, name, bases, ns, **kwargs)
518
+
519
+ return new_cls
520
+
521
+
301
522
  # The Field class can finally be defined.
302
523
  # The __init__ method has to be written manually so Fields can be created
303
524
  # However after this, the other methods can be generated.
304
- class Field:
525
+ class Field(metaclass=SlotMakerMeta):
305
526
  """
306
527
  A basic class to handle the assignment of defaults/factories with
307
528
  some metadata.
@@ -309,16 +530,32 @@ class Field:
309
530
  Intended to be extendable by subclasses for additional features.
310
531
 
311
532
  Note: When run under `pytest`, Field instances are Frozen.
533
+
534
+ When subclassing, passing `frozen=True` will make your subclass frozen.
535
+
536
+ :param default: Standard default value to be used for attributes with this field.
537
+ :param default_factory: A zero-argument function to be called to generate a
538
+ default value, useful for mutable obects like lists.
539
+ :param type: The type of the attribute to be assigned by this field.
540
+ :param doc: The documentation for the attribute that appears when calling
541
+ help(...) on the class. (Only in slotted classes).
542
+ :param init: Include in the class __init__ parameters.
543
+ :param repr: Include in the class __repr__.
544
+ :param compare: Include in the class __eq__.
545
+ :param kw_only: Make this a keyword only parameter in __init__.
312
546
  """
313
- __slots__ = {
314
- "default": "Standard default value to be used for attributes with"
315
- "this field.",
316
- "default_factory": "A 0 argument function to be called to generate "
317
- "a default value, useful for mutable objects like "
318
- "lists.",
319
- "type": "The type of the attribute to be assigned by this field.",
320
- "doc": "The documentation that appears when calling help(...) on the class."
321
- }
547
+ # If this base class did not define __slots__ the metaclass would break it.
548
+ # This will be replaced by the builder.
549
+ __slots__ = SlotFields(
550
+ default=NOTHING,
551
+ default_factory=NOTHING,
552
+ type=NOTHING,
553
+ doc=None,
554
+ init=True,
555
+ repr=True,
556
+ compare=True,
557
+ kw_only=False,
558
+ )
322
559
 
323
560
  # noinspection PyShadowingBuiltins
324
561
  def __init__(
@@ -328,18 +565,50 @@ class Field:
328
565
  default_factory=NOTHING,
329
566
  type=NOTHING,
330
567
  doc=None,
568
+ init=True,
569
+ repr=True,
570
+ compare=True,
571
+ kw_only=False,
331
572
  ):
573
+ # The init function for 'Field' cannot be generated
574
+ # as 'Field' needs to exist first.
575
+ # repr and comparison functions are generated as these
576
+ # do not need to exist to create initial Fields.
577
+
332
578
  self.default = default
333
579
  self.default_factory = default_factory
334
580
  self.type = type
335
581
  self.doc = doc
336
582
 
583
+ self.init = init
584
+ self.repr = repr
585
+ self.compare = compare
586
+ self.kw_only = kw_only
587
+
337
588
  self.validate_field()
338
589
 
590
+ def __init_subclass__(cls, frozen=False):
591
+ field_methods = {_field_init_maker, repr_maker, eq_maker}
592
+ if frozen or _UNDER_TESTING:
593
+ field_methods.update({frozen_setattr_maker, frozen_delattr_maker})
594
+
595
+ builder(
596
+ cls,
597
+ gatherer=unified_gatherer,
598
+ methods=field_methods,
599
+ flags={"slotted": True, "kw_only": True}
600
+ )
601
+
339
602
  def validate_field(self):
603
+ cls_name = self.__class__.__name__
340
604
  if self.default is not NOTHING and self.default_factory is not NOTHING:
341
605
  raise AttributeError(
342
- "Cannot define both a default value and a default factory."
606
+ f"{cls_name} cannot define both a default value and a default factory."
607
+ )
608
+
609
+ if self.kw_only and not self.init:
610
+ raise AttributeError(
611
+ f"{cls_name} cannot be keyword only if it is not in init."
343
612
  )
344
613
 
345
614
  @classmethod
@@ -359,66 +628,6 @@ class Field:
359
628
  return cls(**argument_dict)
360
629
 
361
630
 
362
- class GatheredFields:
363
- __slots__ = ("fields", "modifications")
364
-
365
- def __init__(self, fields, modifications):
366
- self.fields = fields
367
- self.modifications = modifications
368
-
369
- def __call__(self, cls):
370
- return self.fields, self.modifications
371
-
372
-
373
- # Use the builder to generate __repr__ and __eq__ methods
374
- # for both Field and GatheredFields
375
- _field_internal = {
376
- "default": Field(default=NOTHING),
377
- "default_factory": Field(default=NOTHING),
378
- "type": Field(default=NOTHING),
379
- "doc": Field(default=None),
380
- }
381
-
382
- _gathered_field_internal = {
383
- "fields": Field(default=NOTHING),
384
- "modifications": Field(default=NOTHING),
385
- }
386
-
387
- _field_methods = {repr_maker, eq_maker}
388
- if _UNDER_TESTING:
389
- _field_methods.update({frozen_setattr_maker, frozen_delattr_maker})
390
-
391
- builder(
392
- Field,
393
- gatherer=GatheredFields(_field_internal, {}),
394
- methods=_field_methods,
395
- flags={"slotted": True, "kw_only": True},
396
- )
397
-
398
- builder(
399
- GatheredFields,
400
- gatherer=GatheredFields(_gathered_field_internal, {}),
401
- methods={repr_maker, eq_maker},
402
- flags={"slotted": True, "kw_only": False},
403
- )
404
-
405
-
406
- # Slot gathering tools
407
- # Subclass of dict to be identifiable by isinstance checks
408
- # For anything more complicated this could be made into a Mapping
409
- class SlotFields(dict):
410
- """
411
- A plain dict subclass.
412
-
413
- For declaring slotfields there are no additional features required
414
- other than recognising that this is intended to be used as a class
415
- generating dict and isn't a regular dictionary that ended up in
416
- `__slots__`.
417
-
418
- This should be replaced on `__slots__` after fields have been gathered.
419
- """
420
-
421
-
422
631
  def make_slot_gatherer(field_type=Field):
423
632
  """
424
633
  Create a new annotation gatherer that will work with `Field` instances
@@ -428,14 +637,25 @@ def make_slot_gatherer(field_type=Field):
428
637
  :return: A slot gatherer that will check for and generate Fields of
429
638
  the type field_type.
430
639
  """
431
- def field_slot_gatherer(cls):
640
+ def field_slot_gatherer(cls_or_ns):
432
641
  """
433
642
  Gather field information for class generation based on __slots__
434
643
 
435
- :param cls: Class to gather field information from
644
+ :param cls_or_ns: Class to gather field information from (or class namespace)
436
645
  :return: dict of field_name: Field(...)
437
646
  """
438
- cls_slots = cls.__dict__.get("__slots__", None)
647
+ if isinstance(cls_or_ns, (_MappingProxyType, dict)):
648
+ cls_dict = cls_or_ns
649
+ else:
650
+ cls_dict = cls_or_ns.__dict__
651
+
652
+ try:
653
+ cls_slots = cls_dict["__slots__"]
654
+ except KeyError:
655
+ raise AttributeError(
656
+ "__slots__ must be defined as an instance of SlotFields "
657
+ "in order to generate a slotclass"
658
+ )
439
659
 
440
660
  if not isinstance(cls_slots, SlotFields):
441
661
  raise TypeError(
@@ -445,9 +665,7 @@ def make_slot_gatherer(field_type=Field):
445
665
 
446
666
  # Don't want to mutate original annotations so make a copy if it exists
447
667
  # Looking at the dict is a Python3.9 or earlier requirement
448
- cls_annotations = {
449
- **cls.__dict__.get("__annotations__", {})
450
- }
668
+ cls_annotations = get_ns_annotations(cls_dict)
451
669
 
452
670
  cls_fields = {}
453
671
  slot_replacement = {}
@@ -484,43 +702,10 @@ def make_slot_gatherer(field_type=Field):
484
702
  return field_slot_gatherer
485
703
 
486
704
 
487
- slot_gatherer = make_slot_gatherer()
488
-
489
-
490
- # Annotation gathering tools
491
- def is_classvar(hint):
492
- _typing = sys.modules.get("typing")
493
-
494
- if _typing:
495
- # Annotated is a nightmare I'm never waking up from
496
- # 3.8 and 3.9 need Annotated from typing_extensions
497
- # 3.8 also needs get_origin from typing_extensions
498
- if sys.version_info < (3, 10):
499
- _typing_extensions = sys.modules.get("typing_extensions")
500
- if _typing_extensions:
501
- _Annotated = _typing_extensions.Annotated
502
- _get_origin = _typing_extensions.get_origin
503
- else:
504
- _Annotated, _get_origin = None, None
505
- else:
506
- _Annotated = _typing.Annotated
507
- _get_origin = _typing.get_origin
508
-
509
- if _Annotated and _get_origin(hint) is _Annotated:
510
- hint = getattr(hint, "__origin__", None)
511
-
512
- if (
513
- hint is _typing.ClassVar
514
- or getattr(hint, "__origin__", None) is _typing.ClassVar
515
- ):
516
- return True
517
- # String used as annotation
518
- elif isinstance(hint, str) and "ClassVar" in hint:
519
- return True
520
- return False
521
-
522
-
523
- def make_annotation_gatherer(field_type=Field, leave_default_values=True):
705
+ def make_annotation_gatherer(
706
+ field_type=Field,
707
+ leave_default_values=True,
708
+ ):
524
709
  """
525
710
  Create a new annotation gatherer that will work with `Field` instances
526
711
  of the creators definition.
@@ -530,35 +715,51 @@ def make_annotation_gatherer(field_type=Field, leave_default_values=True):
530
715
  default values in place as class variables.
531
716
  :return: An annotation gatherer with these settings.
532
717
  """
533
- def field_annotation_gatherer(cls):
534
- cls_annotations = cls.__dict__.get("__annotations__", {})
718
+ def field_annotation_gatherer(cls_or_ns):
719
+ if isinstance(cls_or_ns, (_MappingProxyType, dict)):
720
+ cls_dict = cls_or_ns
721
+ else:
722
+ cls_dict = cls_or_ns.__dict__
535
723
 
536
724
  cls_fields: dict[str, field_type] = {}
537
-
538
725
  modifications = {}
539
726
 
727
+ cls_annotations = get_ns_annotations(cls_dict)
728
+
729
+ kw_flag = False
730
+
540
731
  for k, v in cls_annotations.items():
541
732
  # Ignore ClassVar
542
733
  if is_classvar(v):
543
734
  continue
544
735
 
545
- attrib = getattr(cls, k, NOTHING)
736
+ if v is KW_ONLY:
737
+ if kw_flag:
738
+ raise SyntaxError("KW_ONLY sentinel may only appear once.")
739
+ kw_flag = True
740
+ continue
741
+
742
+ attrib = cls_dict.get(k, NOTHING)
546
743
 
547
744
  if attrib is not NOTHING:
548
745
  if isinstance(attrib, field_type):
549
- attrib = field_type.from_field(attrib, type=v)
746
+ kw_only = attrib.kw_only or kw_flag
747
+
748
+ attrib = field_type.from_field(attrib, type=v, kw_only=kw_only)
749
+
550
750
  if attrib.default is not NOTHING and leave_default_values:
551
751
  modifications[k] = attrib.default
552
752
  else:
553
753
  # NOTHING sentinel indicates a value should be removed
554
754
  modifications[k] = NOTHING
555
- else:
556
- attrib = field_type(default=attrib, type=v)
755
+ elif not isinstance(attrib, _MemberDescriptorType):
756
+ attrib = field_type(default=attrib, type=v, kw_only=kw_flag)
557
757
  if not leave_default_values:
558
758
  modifications[k] = NOTHING
559
-
759
+ else:
760
+ attrib = field_type(type=v, kw_only=kw_flag)
560
761
  else:
561
- attrib = field_type(type=v)
762
+ attrib = field_type(type=v, kw_only=kw_flag)
562
763
 
563
764
  cls_fields[k] = attrib
564
765
 
@@ -567,8 +768,106 @@ def make_annotation_gatherer(field_type=Field, leave_default_values=True):
567
768
  return field_annotation_gatherer
568
769
 
569
770
 
771
+ def make_field_gatherer(
772
+ field_type=Field,
773
+ leave_default_values=True,
774
+ ):
775
+ def field_attribute_gatherer(cls_or_ns):
776
+ if isinstance(cls_or_ns, (_MappingProxyType, dict)):
777
+ cls_dict = cls_or_ns
778
+ else:
779
+ cls_dict = cls_or_ns.__dict__
780
+
781
+ cls_attributes = {
782
+ k: v
783
+ for k, v in cls_dict.items()
784
+ if isinstance(v, field_type)
785
+ }
786
+ cls_annotations = get_ns_annotations(cls_dict)
787
+
788
+ cls_modifications = {}
789
+
790
+ for name in cls_attributes.keys():
791
+ attrib = cls_attributes[name]
792
+ if leave_default_values:
793
+ cls_modifications[name] = attrib.default
794
+ else:
795
+ cls_modifications[name] = NOTHING
796
+
797
+ if (anno := cls_annotations.get(name, NOTHING)) is not NOTHING:
798
+ cls_attributes[name] = field_type.from_field(attrib, type=anno)
799
+
800
+ return cls_attributes, cls_modifications
801
+ return field_attribute_gatherer
802
+
803
+
804
+ def make_unified_gatherer(
805
+ field_type=Field,
806
+ leave_default_values=True,
807
+ ):
808
+ """
809
+ Create a gatherer that will work via first slots, then
810
+ Field(...) class attributes and finally annotations if
811
+ no unannotated Field(...) attributes are present.
812
+
813
+ :param field_type: The field class to use for gathering
814
+ :param leave_default_values: leave default values in place
815
+ :return: gatherer function
816
+ """
817
+ slot_g = make_slot_gatherer(field_type)
818
+ anno_g = make_annotation_gatherer(field_type, leave_default_values)
819
+ attrib_g = make_field_gatherer(field_type, leave_default_values)
820
+
821
+ def field_unified_gatherer(cls_or_ns):
822
+ if isinstance(cls_or_ns, (_MappingProxyType, dict)):
823
+ cls_dict = cls_or_ns
824
+ else:
825
+ cls_dict = cls_or_ns.__dict__
826
+
827
+ cls_slots = cls_dict.get("__slots__")
828
+
829
+ if isinstance(cls_slots, SlotFields):
830
+ return slot_g(cls_dict)
831
+
832
+ # To choose between annotation and attribute gatherers
833
+ # compare sets of names.
834
+ # Don't bother evaluating string annotations, as we only need names
835
+ cls_annotations = get_ns_annotations(cls_dict, eval_str=False)
836
+ cls_attributes = {
837
+ k: v for k, v in cls_dict.items() if isinstance(v, field_type)
838
+ }
839
+
840
+ cls_annotation_names = cls_annotations.keys()
841
+ cls_attribute_names = cls_attributes.keys()
842
+
843
+ if set(cls_annotation_names).issuperset(set(cls_attribute_names)):
844
+ # All `Field` values have annotations, so use annotation gatherer
845
+ return anno_g(cls_dict)
846
+
847
+ return attrib_g(cls_dict)
848
+ return field_unified_gatherer
849
+
850
+
851
+ slot_gatherer = make_slot_gatherer()
570
852
  annotation_gatherer = make_annotation_gatherer()
571
853
 
854
+ # The unified gatherer used for slot classes must remove default
855
+ # values for slots to work correctly.
856
+ unified_gatherer = make_unified_gatherer(leave_default_values=False)
857
+
858
+
859
+ # Now the gatherers have been defined, add __repr__ and __eq__ to Field.
860
+ _field_methods = {repr_maker, eq_maker}
861
+ if _UNDER_TESTING:
862
+ _field_methods.update({frozen_setattr_maker, frozen_delattr_maker})
863
+
864
+ builder(
865
+ Field,
866
+ gatherer=slot_gatherer,
867
+ methods=_field_methods,
868
+ flags={"slotted": True, "kw_only": True},
869
+ )
870
+
572
871
 
573
872
  def check_argument_order(cls):
574
873
  """
@@ -580,6 +879,9 @@ def check_argument_order(cls):
580
879
  fields = get_fields(cls)
581
880
  used_default = False
582
881
  for k, v in fields.items():
882
+ if v.kw_only or (not v.init):
883
+ continue
884
+
583
885
  if v.default is NOTHING and v.default_factory is NOTHING:
584
886
  if used_default:
585
887
  raise SyntaxError(
@@ -611,51 +913,33 @@ def slotclass(cls=None, /, *, methods=default_methods, syntax_check=True):
611
913
  return cls
612
914
 
613
915
 
614
- def annotationclass(cls=None, /, *, methods=default_methods):
615
- if not cls:
616
- return lambda cls_: annotationclass(cls_, methods=methods)
617
-
618
- cls = builder(cls, gatherer=annotation_gatherer, methods=methods, flags={"slotted": False})
619
-
620
- check_argument_order(cls)
621
-
622
- return cls
916
+ class AnnotationClass(metaclass=SlotMakerMeta):
917
+ __slots__ = {}
623
918
 
919
+ def __init_subclass__(
920
+ cls,
921
+ methods=default_methods,
922
+ gatherer=make_unified_gatherer(leave_default_values=True),
923
+ **kwargs
924
+ ):
925
+ # Check class dict otherwise this will always be True as this base
926
+ # class uses slots.
927
+ slots = "__slots__" in cls.__dict__
624
928
 
625
- _field_init_desc = MethodMaker(
626
- funcname="__init__",
627
- code_generator=get_init_generator(
628
- null=_NothingType(),
629
- extra_code=["self.validate_field()"],
630
- )
631
- )
929
+ builder(cls, gatherer=gatherer, methods=methods, flags={"slotted": slots})
930
+ check_argument_order(cls)
931
+ super().__init_subclass__(**kwargs)
632
932
 
633
933
 
634
- def fieldclass(cls=None, /, *, frozen=False):
934
+ @slotclass
935
+ class GatheredFields:
635
936
  """
636
- This is a special decorator for making Field subclasses using __slots__.
637
- This works by forcing the __init__ method to treat NOTHING as a regular
638
- value. This means *all* instance attributes always have defaults.
639
-
640
- :param cls: Field subclass
641
- :param frozen: Make the field class a frozen class.
642
- Field classes are always frozen when running under `pytest`
643
- :return: Modified subclass
937
+ A helper gatherer for fields that have been gathered externally.
644
938
  """
645
- if not cls:
646
- return lambda cls_: fieldclass(cls_, frozen=frozen)
647
-
648
- field_methods = {_field_init_desc, repr_maker, eq_maker}
649
-
650
- # Always freeze when running tests
651
- if frozen or _UNDER_TESTING:
652
- field_methods.update({frozen_setattr_maker, frozen_delattr_maker})
653
-
654
- cls = builder(
655
- cls,
656
- gatherer=slot_gatherer,
657
- methods=field_methods,
658
- flags={"slotted": True, "kw_only": True}
939
+ __slots__ = SlotFields(
940
+ fields=Field(),
941
+ modifications=Field(),
659
942
  )
660
943
 
661
- return cls
944
+ def __call__(self, cls):
945
+ return self.fields, self.modifications