ducktools-classbuilder 0.1.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.

@@ -0,0 +1,418 @@
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
+ __version__ = "v0.1.0"
23
+
24
+ # Change this name if you make heavy modifications
25
+ INTERNALS_DICT = "__classbuilder_internals__"
26
+
27
+
28
+ def get_internals(cls):
29
+ """
30
+ Utility function to get the internals dictionary
31
+ or return None.
32
+
33
+ As generated classes will always have 'fields'
34
+ and 'local_fields' attributes this will always
35
+ evaluate as 'truthy' if this is a generated class.
36
+
37
+ Usage:
38
+ if internals := get_internals(cls):
39
+ ...
40
+
41
+ :param cls: generated class
42
+ :return: internals dictionary of the class or None
43
+ """
44
+ return getattr(cls, INTERNALS_DICT, None)
45
+
46
+
47
+ def get_fields(cls):
48
+ """
49
+ Utility function to gather the fields dictionary
50
+ from the class internals.
51
+
52
+ :param cls: generated class
53
+ :return: dictionary of keys and Field attribute info
54
+ """
55
+ return getattr(cls, INTERNALS_DICT)["fields"]
56
+
57
+
58
+ def get_inst_fields(inst):
59
+ return {
60
+ k: getattr(inst, k)
61
+ for k in get_fields(type(inst))
62
+ }
63
+
64
+
65
+ # As 'None' can be a meaningful default we need a sentinel value
66
+ # to use to show no value has been provided.
67
+ class _NothingType:
68
+ def __repr__(self):
69
+ return "<NOTHING OBJECT>"
70
+
71
+
72
+ NOTHING = _NothingType()
73
+
74
+
75
+ class MethodMaker:
76
+ """
77
+ The descriptor class to place where methods should be generated.
78
+ This delays the actual generation and `exec` until the method is needed.
79
+
80
+ This is used to convert a code generator that returns code and a globals
81
+ dictionary into a descriptor to assign on a generated class.
82
+ """
83
+ def __init__(self, funcname, code_generator):
84
+ """
85
+ :param funcname: name of the generated function eg `__init__`
86
+ :param code_generator: code generator function to operate on a class.
87
+ """
88
+ self.funcname = funcname
89
+ self.code_generator = code_generator
90
+
91
+ def __repr__(self):
92
+ return f"<MethodMaker for {self.funcname} method>"
93
+
94
+ def __get__(self, instance, cls):
95
+ local_vars = {}
96
+ code, globs = self.code_generator(cls)
97
+ exec(code, globs, local_vars)
98
+ method = local_vars.get(self.funcname)
99
+ method.__qualname__ = f"{cls.__qualname__}.{self.funcname}"
100
+
101
+ # Replace this descriptor on the class with the generated function
102
+ setattr(cls, self.funcname, method)
103
+
104
+ # Use 'get' to return the generated function as a bound method
105
+ # instead of as a regular function for first usage.
106
+ return method.__get__(instance, cls)
107
+
108
+
109
+ def init_maker(cls, *, null=NOTHING, kw_only=False):
110
+ fields = get_fields(cls)
111
+
112
+ arglist = []
113
+ assignments = []
114
+ globs = {}
115
+
116
+ if kw_only:
117
+ arglist.append("*")
118
+
119
+ for k, v in fields.items():
120
+ if v.default is not null:
121
+ globs[f"_{k}_default"] = v.default
122
+ arg = f"{k}=_{k}_default"
123
+ assignment = f"self.{k} = {k}"
124
+ elif v.default_factory is not null:
125
+ globs[f"_{k}_factory"] = v.default_factory
126
+ arg = f"{k}=None"
127
+ assignment = f"self.{k} = _{k}_factory() if {k} is None else {k}"
128
+ else:
129
+ arg = f"{k}"
130
+ assignment = f"self.{k} = {k}"
131
+
132
+ arglist.append(arg)
133
+ assignments.append(assignment)
134
+
135
+ args = ", ".join(arglist)
136
+ assigns = "\n ".join(assignments)
137
+ code = f"def __init__(self, {args}):\n" f" {assigns}\n"
138
+ return code, globs
139
+
140
+
141
+ def repr_maker(cls):
142
+ fields = get_fields(cls)
143
+ content = ", ".join(
144
+ f"{name}={{self.{name}!r}}"
145
+ for name, attrib in fields.items()
146
+ )
147
+ code = (
148
+ f"def __repr__(self):\n"
149
+ f" return f'{{type(self).__qualname__}}({content})'\n"
150
+ )
151
+ globs = {}
152
+ return code, globs
153
+
154
+
155
+ def eq_maker(cls):
156
+ class_comparison = "self.__class__ is other.__class__"
157
+ field_names = get_fields(cls)
158
+
159
+ if field_names:
160
+ selfvals = ",".join(f"self.{name}" for name in field_names)
161
+ othervals = ",".join(f"other.{name}" for name in field_names)
162
+ instance_comparison = f"({selfvals},) == ({othervals},)"
163
+ else:
164
+ instance_comparison = "True"
165
+
166
+ code = (
167
+ f"def __eq__(self, other):\n"
168
+ f" return {instance_comparison} if {class_comparison} else NotImplemented\n"
169
+ )
170
+ globs = {}
171
+
172
+ return code, globs
173
+
174
+
175
+ # As only the __get__ method refers to the class we can use the same
176
+ # Descriptor instances for every class.
177
+ init_desc = MethodMaker("__init__", init_maker)
178
+ repr_desc = MethodMaker("__repr__", repr_maker)
179
+ eq_desc = MethodMaker("__eq__", eq_maker)
180
+ default_methods = frozenset({init_desc, repr_desc, eq_desc})
181
+
182
+
183
+ def builder(cls=None, /, *, gatherer, methods):
184
+ """
185
+ The main builder for class generation
186
+
187
+ :param cls: Class to be analysed and have methods generated
188
+ :param gatherer: Function to gather field information
189
+ :type gatherer: Callable[[type], dict[str, Field]]
190
+ :param methods: MethodMakers to add to the class
191
+ :type methods: set[MethodMaker]
192
+ :return: The modified class (the class itself is modified, but this is expected).
193
+ """
194
+ # Handle `None` to make wrapping with a decorator easier.
195
+ if cls is None:
196
+ return lambda cls_: builder(
197
+ cls_,
198
+ gatherer=gatherer,
199
+ methods=methods,
200
+ )
201
+
202
+ internals = {}
203
+ setattr(cls, INTERNALS_DICT, internals)
204
+
205
+ cls_fields = gatherer(cls)
206
+ internals["local_fields"] = cls_fields
207
+
208
+ mro = cls.__mro__[:-1] # skip 'object' base class
209
+ if mro == (cls,): # special case of no inheritance.
210
+ fields = cls_fields.copy()
211
+ else:
212
+ fields = {}
213
+ for c in reversed(mro):
214
+ try:
215
+ fields.update(get_internals(c)["local_fields"])
216
+ except AttributeError:
217
+ pass
218
+
219
+ internals["fields"] = fields
220
+
221
+ # Assign all of the method generators
222
+ for method in methods:
223
+ setattr(cls, method.funcname, method)
224
+
225
+ return cls
226
+
227
+
228
+ # The Field class can finally be defined.
229
+ # The __init__ method has to be written manually so Fields can be created
230
+ # However after this, the other methods can be generated.
231
+ class Field:
232
+ """
233
+ A basic class to handle the assignment of defaults/factories with
234
+ some metadata.
235
+
236
+ Intended to be extendable by subclasses for additional features.
237
+ """
238
+ __slots__ = {
239
+ "default": "Standard default value to be used for attributes with"
240
+ "this field.",
241
+ "default_factory": "A 0 argument function to be called to generate "
242
+ "a default value, useful for mutable objects like "
243
+ "lists.",
244
+ "type": "The type of the attribute to be assigned by this field.",
245
+ "doc": "The documentation that appears when calling help(...) on the class."
246
+ }
247
+
248
+ # noinspection PyShadowingBuiltins
249
+ def __init__(
250
+ self,
251
+ *,
252
+ default=NOTHING,
253
+ default_factory=NOTHING,
254
+ type=NOTHING,
255
+ doc=None,
256
+ ):
257
+ self.default = default
258
+ self.default_factory = default_factory
259
+ self.type = type
260
+ self.doc = doc
261
+
262
+ self.validate_field()
263
+
264
+ def validate_field(self):
265
+ if self.default is not NOTHING and self.default_factory is not NOTHING:
266
+ raise AttributeError(
267
+ "Cannot define both a default value and a default factory."
268
+ )
269
+
270
+ @classmethod
271
+ def from_field(cls, fld, /, **kwargs):
272
+ """
273
+ Create an instance of field or subclass from another field.
274
+
275
+ This is intended to be used to convert a base
276
+ Field into a subclass.
277
+
278
+ :param fld: field class to convert
279
+ :param kwargs: Additional keyword arguments for subclasses
280
+ :return: new field subclass instance
281
+ """
282
+ argument_dict = {**get_inst_fields(fld), **kwargs}
283
+
284
+ return cls(**argument_dict)
285
+
286
+
287
+ # Use the builder to generate __repr__ and __eq__ methods
288
+ # and pretend `Field` was a built class all along.
289
+ _field_internal = {
290
+ "default": Field(default=NOTHING),
291
+ "default_factory": Field(default=NOTHING),
292
+ "type": Field(default=NOTHING),
293
+ "doc": Field(default=None),
294
+ }
295
+
296
+ builder(
297
+ Field,
298
+ gatherer=lambda cls_: _field_internal,
299
+ methods=frozenset({repr_desc, eq_desc})
300
+ )
301
+
302
+
303
+ # Subclass of dict to be identifiable by isinstance checks
304
+ # For anything more complicated this could be made into a Mapping
305
+ class SlotFields(dict):
306
+ """
307
+ A plain dict subclass.
308
+
309
+ For declaring slotfields there are no additional features required
310
+ other than recognising that this is intended to be used as a class
311
+ generating dict and isn't a regular dictionary that ended up in
312
+ `__slots__`.
313
+
314
+ This should be replaced on `__slots__` after fields have been gathered.
315
+ """
316
+
317
+
318
+ def slot_gatherer(cls):
319
+ """
320
+ Gather field information for class generation based on __slots__
321
+
322
+ :param cls: Class to gather field information from
323
+ :return: dict of field_name: Field(...)
324
+ """
325
+ cls_slots = cls.__dict__.get("__slots__", None)
326
+
327
+ if not isinstance(cls_slots, SlotFields):
328
+ raise TypeError(
329
+ "__slots__ must be an instance of SlotFields "
330
+ "in order to generate a slotclass"
331
+ )
332
+
333
+ cls_annotations = cls.__dict__.get("__annotations__", {})
334
+ cls_fields = {}
335
+ slot_replacement = {}
336
+
337
+ for k, v in cls_slots.items():
338
+ if isinstance(v, Field):
339
+ attrib = v
340
+ if v.type is not NOTHING:
341
+ cls_annotations[k] = attrib.type
342
+ else:
343
+ # Plain values treated as defaults
344
+ attrib = Field(default=v)
345
+
346
+ slot_replacement[k] = attrib.doc
347
+ cls_fields[k] = attrib
348
+
349
+ # Replace the SlotAttributes instance with a regular dict
350
+ # So that help() works
351
+ setattr(cls, "__slots__", slot_replacement)
352
+
353
+ # Update annotations with any types from the slots assignment
354
+ setattr(cls, "__annotations__", cls_annotations)
355
+ return cls_fields
356
+
357
+
358
+ def slotclass(cls=None, /, *, methods=default_methods, syntax_check=True):
359
+ """
360
+ Example of class builder in action using __slots__ to find fields.
361
+
362
+ :param cls: Class to be analysed and modified
363
+ :param methods: MethodMakers to be added to the class
364
+ :param syntax_check: check there are no arguments without defaults
365
+ after arguments with defaults.
366
+ :return: Modified class
367
+ """
368
+ if not cls:
369
+ return lambda cls_: slotclass(cls_, methods=methods, syntax_check=syntax_check)
370
+
371
+ cls = builder(cls, gatherer=slot_gatherer, methods=methods)
372
+
373
+ if syntax_check:
374
+ fields = get_fields(cls)
375
+ used_default = False
376
+ for k, v in fields.items():
377
+ if v.default is NOTHING and v.default_factory is NOTHING:
378
+ if used_default:
379
+ raise SyntaxError(
380
+ f"non-default argument {k!r} follows default argument"
381
+ )
382
+ else:
383
+ used_default = True
384
+
385
+ return cls
386
+
387
+
388
+ def fieldclass(cls):
389
+ """
390
+ This is a special decorator for making Field subclasses using __slots__.
391
+ This works by forcing the __init__ method to treat NOTHING as a regular
392
+ value. This means *all* instance attributes always have defaults.
393
+
394
+ :param cls: Field subclass
395
+ :return: Modified subclass
396
+ """
397
+
398
+ # Fields need a way to call their validate method
399
+ # So append it to the code from __init__.
400
+ def field_init_func(cls_):
401
+ code, globs = init_maker(cls_, null=field_nothing, kw_only=True)
402
+ code += " self.validate_field()\n"
403
+ return code, globs
404
+
405
+ field_nothing = _NothingType()
406
+ field_init_desc = MethodMaker(
407
+ "__init__",
408
+ field_init_func,
409
+ )
410
+ field_methods = frozenset({field_init_desc, repr_desc, eq_desc})
411
+
412
+ cls = builder(
413
+ cls,
414
+ gatherer=slot_gatherer,
415
+ methods=field_methods
416
+ )
417
+
418
+ return cls
@@ -0,0 +1,111 @@
1
+ import typing
2
+ from collections.abc import Callable
3
+
4
+ __version__: str
5
+ INTERNALS_DICT: str
6
+
7
+ def get_internals(cls) -> dict[str, typing.Any] | None: ...
8
+
9
+ def get_fields(cls: type) -> dict[str, Field]: ...
10
+
11
+ def get_inst_fields(inst: typing.Any) -> dict[str, typing.Any]: ...
12
+
13
+ class _NothingType:
14
+ ...
15
+ NOTHING: _NothingType
16
+
17
+ # Stub Only
18
+ _codegen_type = Callable[[type], tuple[str, dict[str, typing.Any]]]
19
+
20
+ class MethodMaker:
21
+ funcname: str
22
+ code_generator: _codegen_type
23
+ def __init__(self, funcname: str, code_generator: _codegen_type) -> None: ...
24
+ def __repr__(self) -> str: ...
25
+ def __get__(self, instance, cls) -> Callable: ...
26
+
27
+ def init_maker(
28
+ cls: type,
29
+ *,
30
+ null: _NothingType = NOTHING,
31
+ kw_only: bool = False
32
+ ) -> tuple[str, dict[str, typing.Any]]: ...
33
+ def repr_maker(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
34
+ def eq_maker(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
35
+
36
+ init_desc: MethodMaker
37
+ repr_desc: MethodMaker
38
+ eq_desc: MethodMaker
39
+ default_methods: frozenset[MethodMaker]
40
+
41
+ @typing.overload
42
+ def builder(
43
+ cls: type,
44
+ /,
45
+ *,
46
+ gatherer: Callable[[type], dict[str, Field]],
47
+ methods: frozenset[MethodMaker] | set[MethodMaker]
48
+ ) -> typing.Any: ...
49
+
50
+ @typing.overload
51
+ def builder(
52
+ cls: None = None,
53
+ /,
54
+ *,
55
+ gatherer: Callable[[type], dict[str, Field]],
56
+ methods: frozenset[MethodMaker] | set[MethodMaker]
57
+ ) -> Callable[[type], type]: ...
58
+
59
+
60
+ _Self = typing.TypeVar("_Self", bound="Field")
61
+
62
+ class Field:
63
+ default: _NothingType | typing.Any
64
+ default_factory: _NothingType | typing.Any
65
+ type: _NothingType | type
66
+ doc: None | str
67
+
68
+ def __init__(
69
+ self,
70
+ *,
71
+ default: _NothingType | typing.Any = NOTHING,
72
+ default_factory: _NothingType | typing.Any = NOTHING,
73
+ type: _NothingType | type = NOTHING,
74
+ doc: None | str = None,
75
+ ) -> None: ...
76
+ @property
77
+ def _inherited_slots(self) -> list[str]: ...
78
+ def __repr__(self) -> str: ...
79
+ @typing.overload
80
+ def __eq__(self, other: _Self) -> bool: ...
81
+ @typing.overload
82
+ def __eq__(self, other: object) -> NotImplemented: ...
83
+ def validate_field(self) -> None: ...
84
+ @classmethod
85
+ def from_field(cls, fld: Field, **kwargs: typing.Any) -> _Self: ...
86
+
87
+
88
+ class SlotFields(dict):
89
+ ...
90
+
91
+ def slot_gatherer(cls: type) -> dict[str, Field]:
92
+ ...
93
+
94
+ @typing.overload
95
+ def slotclass(
96
+ cls: type,
97
+ /,
98
+ *,
99
+ methods: frozenset[MethodMaker] | set[MethodMaker] = default_methods,
100
+ syntax_check: bool = True
101
+ ) -> typing.Any: ...
102
+
103
+ def slotclass(
104
+ cls: None = None,
105
+ /,
106
+ *,
107
+ methods: frozenset[MethodMaker] | set[MethodMaker] = default_methods,
108
+ syntax_check: bool = True
109
+ ) -> Callable[[type], type]: ...
110
+
111
+ def fieldclass(cls: type) -> typing.Any: ...