Skip to content

Commit 74beaa2

Browse files
authored
Merge pull request #74 from DavidCEllis/add-generated-example
Update the readme, move hash related code to core
2 parents 1f88ffe + 2fb987f commit 74beaa2

File tree

9 files changed

+337
-80
lines changed

9 files changed

+337
-80
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.venv
12
.idea
23
.mypy_cache
34
.pytest_cache

README.md

Lines changed: 190 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,81 @@
11
# Ducktools: Class Builder #
22

3-
`ducktools-classbuilder` is *the* Python package that will bring you the **joy**
4-
of writing... functions... that will bring back the **joy** of writing classes.
3+
`ducktools-classbuilder` is both an alternate implementation of the dataclasses concept
4+
along with a toolkit for creating your own customised implementation.
55

6-
Maybe.
6+
Create classes using type annotations:
77

8-
While `attrs` and `dataclasses` are class boilerplate generators,
9-
`ducktools.classbuilder` is intended to provide the tools to help make a customized
10-
version of the same concept.
8+
```python
9+
from ducktools.classbuilder.prefab import prefab
1110

12-
Install from PyPI with:
13-
`python -m pip install ducktools-classbuilder`
11+
@prefab
12+
class Book:
13+
title: str = "The Hitchhikers Guide to the Galaxy"
14+
author: str = "Douglas Adams"
15+
year: int = 1979
16+
```
1417

15-
## Included Implementations ##
18+
Using `attribute()` calls (this may look familiar to `attrs` users before Python added
19+
type annotations)
1620

17-
The classbuilder tools make up the core of this module and there is an implementation
18-
using these tools in the `prefab` submodule.
21+
```python
22+
from ducktools.classbuilder.prefab import attribute, prefab
23+
24+
@prefab
25+
class Book:
26+
title = attribute(default="The Hitchhikers Guide to the Galaxy")
27+
author = attribute(default="Douglas Adams")
28+
year = attribute(default=1979)
29+
```
1930

20-
The implementation provides both a base class `Prefab` that will also generate `__slots__`
21-
and a decorator `@prefab` which does not support `__slots__`.
31+
Or using a special mapping for slots:
2232

2333
```python
34+
from ducktools.classbuilder.prefab import SlotFields, prefab
35+
36+
@prefab
37+
class Book:
38+
__slots__ = SlotFields(
39+
title="The Hitchhikers Guide to the Galaxy",
40+
author="Douglas Adams",
41+
year=1979,
42+
)
43+
```
44+
45+
As with `dataclasses` or `attrs`, `ducktools-classbuilder` will handle writing the
46+
boilerplate `__init__`, `__eq__` and `__repr__` functions for you.
47+
48+
Unlike `dataclasses` or `attrs`, `ducktools-classbuilder` generates and executes its
49+
templated functions lazily, so they are only executed if and when the methods are first
50+
used. This significantly reduces the time taken to create the classes as unused methods
51+
are never generated. Before generation occurs, the descriptors can be seen in the class
52+
`__dict__`, after first use these are replaced.
53+
54+
```python
55+
>>> Book.__dict__["__init__"]
56+
<MethodMaker for '__init__' method>
57+
>>> Book()
58+
Book(title='The Hitchhikers Guide to the Galaxy', author='Douglas Adams', year=1979)
59+
>>> Book.__dict__["__init__"]
60+
<function Book.__init__ at ...>
61+
```
62+
63+
The gathering of field and class information is also separated from the build step
64+
so it is possible to change how this information is gathered without needing to rewrite
65+
the code generation tools.
66+
67+
## The base class `Prefab` implementation ##
68+
69+
Alongside the `@prefab` decorator there is also a `Prefab` base class that can be used.
70+
71+
The main differences in behaviour are that `Prefab` will generate `__slots__` by default
72+
using a metaclass, and any options given to `Prefab` will automatically be set on subclasses.
73+
74+
Unlike attrs' `@define` or dataclasses' `@dataclass`, `@prefab` does not and will not support
75+
`__slots__` (this is explained in a section below).
76+
77+
```python
78+
from pathlib import Path
2479
from ducktools.classbuilder.prefab import Prefab, attribute
2580

2681
class Slotted(Prefab):
@@ -29,40 +84,127 @@ class Slotted(Prefab):
2984
default="What do you get if you multiply six by nine?",
3085
doc="Life the universe and everything",
3186
)
87+
python_path: Path("/usr/bin/python4")
3288

3389
ex = Slotted()
3490
print(ex)
35-
print(ex.__slots__)
3691
```
3792

38-
The generated source code for the methods can be viewed using the `print_generated_code` helper function.
93+
The generated code for the methods can be viewed using the `print_generated_code` helper function.
94+
95+
<details>
96+
97+
<summary>Generated source code for the same example, but with all optional methods enabled</summary>
3998

4099
```python
41-
from ducktools.classbuilder import print_generated_code
100+
Source:
101+
def __delattr__(self, name):
102+
raise TypeError(
103+
f"{type(self).__name__!r} object "
104+
f"does not support attribute deletion"
105+
)
106+
107+
def __eq__(self, other):
108+
return (
109+
self.the_answer == other.the_answer
110+
and self.the_question == other.the_question
111+
and self.python_path == other.python_path
112+
) if self.__class__ is other.__class__ else NotImplemented
113+
114+
def __ge__(self, other):
115+
if self.__class__ is other.__class__:
116+
return (self.the_answer, self.the_question, self.python_path) >= (other.the_answer, other.the_question, other.python_path)
117+
return NotImplemented
118+
119+
def __gt__(self, other):
120+
if self.__class__ is other.__class__:
121+
return (self.the_answer, self.the_question, self.python_path) > (other.the_answer, other.the_question, other.python_path)
122+
return NotImplemented
123+
124+
def __hash__(self):
125+
return hash((self.the_answer, self.the_question, self.python_path))
126+
127+
def __init__(self, the_answer=42, the_question='What do you get if you multiply six by nine?', python_path=_python_path_default):
128+
self.the_answer = the_answer
129+
self.the_question = the_question
130+
self.python_path = python_path
131+
132+
def __iter__(self):
133+
yield self.the_answer
134+
yield self.the_question
135+
yield self.python_path
136+
137+
def __le__(self, other):
138+
if self.__class__ is other.__class__:
139+
return (self.the_answer, self.the_question, self.python_path) <= (other.the_answer, other.the_question, other.python_path)
140+
return NotImplemented
141+
142+
def __lt__(self, other):
143+
if self.__class__ is other.__class__:
144+
return (self.the_answer, self.the_question, self.python_path) < (other.the_answer, other.the_question, other.python_path)
145+
return NotImplemented
146+
147+
def __replace__(self, /, **changes):
148+
new_kwargs = {'the_answer': self.the_answer, 'the_question': self.the_question, 'python_path': self.python_path}
149+
new_kwargs |= changes
150+
return self.__class__(**new_kwargs)
151+
152+
@_recursive_repr
153+
def __repr__(self):
154+
return f'{type(self).__qualname__}(the_answer={self.the_answer!r}, the_question={self.the_question!r}, python_path={self.python_path!r})'
155+
156+
def __setattr__(self, name, value):
157+
if hasattr(self, name) or name not in __field_names:
158+
raise TypeError(
159+
f"{type(self).__name__!r} object does not support "
160+
f"attribute assignment"
161+
)
162+
else:
163+
__setattr_func(self, name, value)
164+
165+
def as_dict(self):
166+
return {'the_answer': self.the_answer, 'the_question': self.the_question, 'python_path': self.python_path}
167+
168+
169+
Globals:
170+
__init__: {'_python_path_default': PosixPath('/usr/bin/python')}
171+
__repr__: {'_recursive_repr': <function recursive_repr.<locals>.decorating_function at 0x7367f9cddf30>}
172+
__setattr__: {'__field_names': {'the_question', 'the_answer', 'python_path'}, '__setattr_func': <slot wrapper '__setattr__' of 'object' objects>}
173+
174+
Annotations:
175+
__init__: {'the_answer': <class 'int'>, 'the_question': <class 'str'>, 'python_path': <class 'pathlib.Path'>, 'return': None}
42176

43-
print_generated_code(Slotted)
44177
```
45178

179+
</details>
180+
46181
### Core ###
47182

48-
The base `ducktools.classbuilder` module provides tools for creating a customized version of the `dataclass` concept.
183+
The main `ducktools.classbuilder` module provides tools for creating a customized version of the `dataclass` concept.
49184

50185
* `MethodMaker`
51186
* This tool takes a function that generates source code and converts it into a descriptor
52187
that will execute the source code and attach the gemerated method to a class on demand.
188+
* This is what you use if you need to write a customized `__init__` method or add some other
189+
generated method.
53190
* `Field`
54191
* This defines a basic dataclass-like field with some basic arguments
55192
* This class itself is a dataclass-like of sorts
193+
(unfortunately it does not play well with `@dataclass_transform` and hence, typing)
56194
* Additional arguments can be added by subclassing and using annotations
57195
* See `ducktools.classbuilder.prefab.Attribute` for an example of this
58196
* Gatherers
59197
* These collect field information and return both the gathered fields and any modifications
60198
that will need to be made to the class when built to support them.
199+
* This is what you would use if, for instance you wanted to use `Annotated[...]` to define
200+
how fields should act instead of arguments. The full documentation includes an example
201+
implementing this for a simple dataclass-like.
61202
* `builder`
62203
* This is the main tool used for constructing decorators and base classes to provide
63204
generated methods.
64205
* Other than the required changes to a class for `__slots__` that are done by `SlotMakerMeta`
65206
this is where all class mutations should be applied.
207+
* Once you have a gatherer and a set of `MethodMaker`s run this to add the methods to the class
66208
* `SlotMakerMeta`
67209
* When given a gatherer, this metaclass will create `__slots__` automatically.
68210

@@ -76,12 +218,18 @@ The base `ducktools.classbuilder` module provides tools for creating a customize
76218
77219
### Prefab ###
78220

79-
This prebuilt implementation is available from the `ducktools.classbuilder.prefab` submodule.
221+
The prebuilt 'prefab' implementation includes additional customization including
222+
`__prefab_pre_init__` and `__prefab_post_init__` methods.
223+
224+
Both of these methods will take any field names as arguments. Those passed to `__prefab_pre_init__` will still be set
225+
inside the main `__init__` body, while those passed to `__prefab_post_init__` will not.
226+
227+
`__prefab_pre_init__` is intended as a place to perform validation checks before values are set in the main body.
228+
`__prefab_post_init__` can be seen as a partial `__init__` function, where you only need to write
229+
the `__init__` function for arguments that need more than basic assignment.
80230

81-
This includes more customization including `__prefab_pre_init__` and `__prefab_post_init__`
82-
functions for subclass customization.
231+
Here is an example using `__prefab_post_init__` that converts a string or Path object into a path object:
83232

84-
Here is an example of applying a conversion in `__prefab_post_init__`:
85233
```python
86234
from pathlib import Path
87235
from ducktools.classbuilder.prefab import Prefab
@@ -103,14 +251,29 @@ steam = AppDetails(
103251
print(steam)
104252
```
105253

106-
#### Features ####
254+
<details>
255+
256+
<summary>The generated code for the init method</summary>
257+
258+
```python
259+
def __init__(self, app_name, app_path):
260+
self.app_name = app_name
261+
self.__prefab_post_init__(app_path=app_path)
262+
```
263+
264+
Note: annotations are attached as `__annotations__` and so do not appear in generated
265+
source code.
266+
267+
</details>
268+
269+
#### Features and Differences ####
107270

108271
`Prefab` and `@prefab` support many standard dataclass features along with
109272
some extra features and some intentional differences in design.
110273

111274
* All standard methods are generated on-demand
112275
* This makes the construction of classes much faster in general
113-
* Generation is done and then cached on first access
276+
* Generation is done and then cached on first access using non-data descriptors
114277
* Standard `__init__`, `__eq__` and `__repr__` methods are generated by default
115278
- The `__repr__` implementation does not automatically protect against recursion,
116279
but there is a `recursive_repr` argument that will do so if needed
@@ -155,7 +318,9 @@ There are also some intentionally missing features:
155318
* `VALUE` annotations are used as they are faster in most cases
156319
* As the `__init__` method gets `__annotations__` these need to be either values or strings
157320
to match the behaviour of previous Python versions
158-
321+
* There is currently no equivalent to `InitVar`
322+
* I'm not sure *how* I would want to implement this other than I don't _really_ want to use
323+
annotations to decide behaviour (this is messy enough with `ClassVar` and `KW_ONLY`).
159324

160325
## What is the issue with generating `__slots__` with a decorator ##
161326

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ docs = [
3737
dev = [
3838
"pytest>=8.4",
3939
"pytest-cov>=6.1",
40-
"mypy>=1.16",
40+
"mypy>=1.16; platform_python_implementation == 'CPython'",
4141
"typing-extensions>=4.14",
4242
]
4343
performance = [

scripts/source_code_demo.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from pathlib import Path
2+
3+
from ducktools.classbuilder import print_generated_code
4+
from ducktools.classbuilder.prefab import Prefab, attribute
5+
6+
7+
class Example(Prefab, order=True, iter=True, frozen=True, dict_method=True, recursive_repr=True):
8+
the_answer: int = 42
9+
the_question: str = attribute(
10+
default="What do you get if you multiply six by nine?",
11+
doc="Life the universe and everything",
12+
)
13+
python_path: Path = Path("/usr/bin/python")
14+
15+
print_generated_code(Example)

src/ducktools/classbuilder/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ def frozen_setattr_generator(cls, funcname="__setattr__"):
560560
body = (
561561
f" if {hasattr_check} or name not in __field_names:\n"
562562
f' raise TypeError(\n'
563-
f' f"{{type(self).__name__!r}} object does not support "'
563+
f' f"{{type(self).__name__!r}} object does not support "\n'
564564
f' f"attribute assignment"\n'
565565
f' )\n'
566566
f" else:\n"
@@ -583,6 +583,20 @@ def frozen_delattr_generator(cls, funcname="__delattr__"):
583583
return GeneratedCode(code, globs)
584584

585585

586+
def hash_generator(cls, funcname="__hash__"):
587+
fields = get_fields(cls)
588+
vals = ", ".join(
589+
f"self.{name}"
590+
for name, attrib in fields.items()
591+
if attrib.compare
592+
)
593+
if len(fields) == 1:
594+
vals += ","
595+
code = f"def {funcname}(self):\n return hash(({vals}))\n"
596+
globs = {}
597+
return GeneratedCode(code, globs)
598+
599+
586600
# As only the __get__ method refers to the class we can use the same
587601
# Descriptor instances for every class.
588602
init_maker = MethodMaker("__init__", init_generator)
@@ -595,6 +609,7 @@ def frozen_delattr_generator(cls, funcname="__delattr__"):
595609
replace_maker = MethodMaker("__replace__", replace_generator)
596610
frozen_setattr_maker = MethodMaker("__setattr__", frozen_setattr_generator)
597611
frozen_delattr_maker = MethodMaker("__delattr__", frozen_delattr_generator)
612+
hash_maker = MethodMaker("__hash__", hash_generator)
598613
default_methods = frozenset({init_maker, repr_maker, eq_maker})
599614

600615
# Special `__init__` maker for 'Field' subclasses - needs its own NOTHING option

src/ducktools/classbuilder/__init__.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ def ge_generator(cls: type, funcname: str = ...) -> GeneratedCode: ...
123123
def replace_generator(cls: type, funcname: str = "__replace__") -> GeneratedCode: ...
124124

125125
def frozen_setattr_generator(cls: type, funcname: str = "__setattr__") -> GeneratedCode: ...
126-
127126
def frozen_delattr_generator(cls: type, funcname: str = "__delattr__") -> GeneratedCode: ...
127+
def hash_generator(cls: type, funcname: str = ...) -> GeneratedCode: ...
128128

129129
init_maker: MethodMaker
130130
repr_maker: MethodMaker
@@ -136,6 +136,7 @@ ge_maker: MethodMaker
136136
replace_maker: MethodMaker
137137
frozen_setattr_maker: MethodMaker
138138
frozen_delattr_maker: MethodMaker
139+
hash_maker: MethodMaker
139140
default_methods: frozenset[MethodMaker]
140141

141142
_TypeT = typing.TypeVar("_TypeT", bound=type)

0 commit comments

Comments
 (0)