ducktools-classbuilder 0.5.0__py3-none-any.whl → 0.6.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,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.0"
35
+ from .annotations import get_ns_annotations, is_classvar
36
+
37
+ __version__ = "v0.6.0"
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,19 +158,32 @@ 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)
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):
@@ -115,29 +192,51 @@ def get_init_generator(null=NOTHING, extra_code=None):
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}"
136
214
 
137
- arglist.append(arg)
138
- assignments.append(assignment)
215
+ if kw_only_flag or v.kw_only:
216
+ kw_only_arglist.append(arg)
217
+ else:
218
+ arglist.append(arg)
219
+
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
242
  f"def __init__(self, {args}):\n"
@@ -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):
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 __repr__(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 __repr__(self):\n"
305
+ f" return f'<generated class {{type(self).__qualname__}}>'\n"
306
+ )
307
+ else:
308
+ code = (
309
+ f"{recursion_func}"
310
+ f"def __repr__(self):\n"
311
+ f" return f'{{type(self).__qualname__}}({content})'\n"
312
+ )
313
+
314
+ return GeneratedCode(code, globs)
315
+ return cls_repr_generator
316
+
317
+
318
+ repr_generator = get_repr_generator()
172
319
 
173
320
 
174
321
  def eq_generator(cls):
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)
@@ -188,7 +339,7 @@ def eq_generator(cls):
188
339
  )
189
340
  globs = {}
190
341
 
191
- return code, globs
342
+ return GeneratedCode(code, globs)
192
343
 
193
344
 
194
345
  def frozen_setattr_generator(cls):
@@ -217,7 +368,7 @@ def frozen_setattr_generator(cls):
217
368
  )
218
369
  code = f"def __setattr__(self, name, value):\n{body}"
219
370
 
220
- return code, globs
371
+ return GeneratedCode(code, globs)
221
372
 
222
373
 
223
374
  def frozen_delattr_generator(cls):
@@ -229,7 +380,7 @@ def frozen_delattr_generator(cls):
229
380
  )
230
381
  code = f"def __delattr__(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,43 +628,6 @@ class Field:
359
628
  return cls(**argument_dict)
360
629
 
361
630
 
362
- # Use the builder to generate __repr__ and __eq__ methods
363
- # and pretend `Field` was a built class all along.
364
- _field_internal = {
365
- "default": Field(default=NOTHING),
366
- "default_factory": Field(default=NOTHING),
367
- "type": Field(default=NOTHING),
368
- "doc": Field(default=None),
369
- }
370
-
371
- _field_methods = {repr_maker, eq_maker}
372
- if _UNDER_TESTING:
373
- _field_methods.update({frozen_setattr_maker, frozen_delattr_maker})
374
-
375
- builder(
376
- Field,
377
- gatherer=lambda cls_: (_field_internal, {}),
378
- methods=_field_methods,
379
- flags={"slotted": True, "kw_only": True},
380
- )
381
-
382
-
383
- # Slot gathering tools
384
- # Subclass of dict to be identifiable by isinstance checks
385
- # For anything more complicated this could be made into a Mapping
386
- class SlotFields(dict):
387
- """
388
- A plain dict subclass.
389
-
390
- For declaring slotfields there are no additional features required
391
- other than recognising that this is intended to be used as a class
392
- generating dict and isn't a regular dictionary that ended up in
393
- `__slots__`.
394
-
395
- This should be replaced on `__slots__` after fields have been gathered.
396
- """
397
-
398
-
399
631
  def make_slot_gatherer(field_type=Field):
400
632
  """
401
633
  Create a new annotation gatherer that will work with `Field` instances
@@ -405,14 +637,25 @@ def make_slot_gatherer(field_type=Field):
405
637
  :return: A slot gatherer that will check for and generate Fields of
406
638
  the type field_type.
407
639
  """
408
- def field_slot_gatherer(cls):
640
+ def field_slot_gatherer(cls_or_ns):
409
641
  """
410
642
  Gather field information for class generation based on __slots__
411
643
 
412
- :param cls: Class to gather field information from
644
+ :param cls_or_ns: Class to gather field information from (or class namespace)
413
645
  :return: dict of field_name: Field(...)
414
646
  """
415
- 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
+ )
416
659
 
417
660
  if not isinstance(cls_slots, SlotFields):
418
661
  raise TypeError(
@@ -422,14 +665,19 @@ def make_slot_gatherer(field_type=Field):
422
665
 
423
666
  # Don't want to mutate original annotations so make a copy if it exists
424
667
  # Looking at the dict is a Python3.9 or earlier requirement
425
- cls_annotations = {
426
- **cls.__dict__.get("__annotations__", {})
427
- }
668
+ cls_annotations = get_ns_annotations(cls_dict)
428
669
 
429
670
  cls_fields = {}
430
671
  slot_replacement = {}
431
672
 
432
673
  for k, v in cls_slots.items():
674
+ # Special case __dict__ and __weakref__
675
+ # They should be included in the final `__slots__`
676
+ # But ignored as a value.
677
+ if k in {"__dict__", "__weakref__"}:
678
+ slot_replacement[k] = None
679
+ continue
680
+
433
681
  if isinstance(v, field_type):
434
682
  attrib = v
435
683
  if attrib.type is not NOTHING:
@@ -454,43 +702,10 @@ def make_slot_gatherer(field_type=Field):
454
702
  return field_slot_gatherer
455
703
 
456
704
 
457
- slot_gatherer = make_slot_gatherer()
458
-
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
475
- else:
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):
705
+ def make_annotation_gatherer(
706
+ field_type=Field,
707
+ leave_default_values=True,
708
+ ):
494
709
  """
495
710
  Create a new annotation gatherer that will work with `Field` instances
496
711
  of the creators definition.
@@ -500,35 +715,51 @@ def make_annotation_gatherer(field_type=Field, leave_default_values=True):
500
715
  default values in place as class variables.
501
716
  :return: An annotation gatherer with these settings.
502
717
  """
503
- def field_annotation_gatherer(cls):
504
- 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__
505
723
 
506
724
  cls_fields: dict[str, field_type] = {}
507
-
508
725
  modifications = {}
509
726
 
727
+ cls_annotations = get_ns_annotations(cls_dict)
728
+
729
+ kw_flag = False
730
+
510
731
  for k, v in cls_annotations.items():
511
732
  # Ignore ClassVar
512
733
  if is_classvar(v):
513
734
  continue
514
735
 
515
- 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)
516
743
 
517
744
  if attrib is not NOTHING:
518
745
  if isinstance(attrib, field_type):
519
- 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
+
520
750
  if attrib.default is not NOTHING and leave_default_values:
521
751
  modifications[k] = attrib.default
522
752
  else:
523
753
  # NOTHING sentinel indicates a value should be removed
524
754
  modifications[k] = NOTHING
525
- else:
526
- 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)
527
757
  if not leave_default_values:
528
758
  modifications[k] = NOTHING
529
-
759
+ else:
760
+ attrib = field_type(type=v, kw_only=kw_flag)
530
761
  else:
531
- attrib = field_type(type=v)
762
+ attrib = field_type(type=v, kw_only=kw_flag)
532
763
 
533
764
  cls_fields[k] = attrib
534
765
 
@@ -537,8 +768,106 @@ def make_annotation_gatherer(field_type=Field, leave_default_values=True):
537
768
  return field_annotation_gatherer
538
769
 
539
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()
540
852
  annotation_gatherer = make_annotation_gatherer()
541
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
+
542
871
 
543
872
  def check_argument_order(cls):
544
873
  """
@@ -550,6 +879,9 @@ def check_argument_order(cls):
550
879
  fields = get_fields(cls)
551
880
  used_default = False
552
881
  for k, v in fields.items():
882
+ if v.kw_only or (not v.init):
883
+ continue
884
+
553
885
  if v.default is NOTHING and v.default_factory is NOTHING:
554
886
  if used_default:
555
887
  raise SyntaxError(
@@ -581,51 +913,33 @@ def slotclass(cls=None, /, *, methods=default_methods, syntax_check=True):
581
913
  return cls
582
914
 
583
915
 
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
916
+ class AnnotationClass(metaclass=SlotMakerMeta):
917
+ __slots__ = {}
593
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__
594
928
 
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
- )
929
+ builder(cls, gatherer=gatherer, methods=methods, flags={"slotted": slots})
930
+ check_argument_order(cls)
931
+ super().__init_subclass__(**kwargs)
602
932
 
603
933
 
604
- def fieldclass(cls=None, /, *, frozen=False):
934
+ @slotclass
935
+ class GatheredFields:
605
936
  """
606
- This is a special decorator for making Field subclasses using __slots__.
607
- This works by forcing the __init__ method to treat NOTHING as a regular
608
- value. This means *all* instance attributes always have defaults.
609
-
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`
613
- :return: Modified subclass
937
+ A helper gatherer for fields that have been gathered externally.
614
938
  """
615
- if not cls:
616
- return lambda cls_: fieldclass(cls_, frozen=frozen)
617
-
618
- field_methods = {_field_init_desc, repr_maker, eq_maker}
619
-
620
- # Always freeze when running tests
621
- if frozen or _UNDER_TESTING:
622
- field_methods.update({frozen_setattr_maker, frozen_delattr_maker})
623
-
624
- cls = builder(
625
- cls,
626
- gatherer=slot_gatherer,
627
- methods=field_methods,
628
- flags={"slotted": True, "kw_only": True}
939
+ __slots__ = SlotFields(
940
+ fields=Field(),
941
+ modifications=Field(),
629
942
  )
630
943
 
631
- return cls
944
+ def __call__(self, cls):
945
+ return self.fields, self.modifications