Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.venv
.idea
.mypy_cache
.pytest_cache
Expand Down
215 changes: 190 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,81 @@
# Ducktools: Class Builder #

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

Maybe.
Create classes using type annotations:

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

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

## Included Implementations ##
Using `attribute()` calls (this may look familiar to `attrs` users before Python added
type annotations)

The classbuilder tools make up the core of this module and there is an implementation
using these tools in the `prefab` submodule.
```python
from ducktools.classbuilder.prefab import attribute, prefab

@prefab
class Book:
title = attribute(default="The Hitchhikers Guide to the Galaxy")
author = attribute(default="Douglas Adams")
year = attribute(default=1979)
```

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

```python
from ducktools.classbuilder.prefab import SlotFields, prefab

@prefab
class Book:
__slots__ = SlotFields(
title="The Hitchhikers Guide to the Galaxy",
author="Douglas Adams",
year=1979,
)
```

As with `dataclasses` or `attrs`, `ducktools-classbuilder` will handle writing the
boilerplate `__init__`, `__eq__` and `__repr__` functions for you.

Unlike `dataclasses` or `attrs`, `ducktools-classbuilder` generates and executes its
templated functions lazily, so they are only executed if and when the methods are first
used. This significantly reduces the time taken to create the classes as unused methods
are never generated. Before generation occurs, the descriptors can be seen in the class
`__dict__`, after first use these are replaced.

```python
>>> Book.__dict__["__init__"]
<MethodMaker for '__init__' method>
>>> Book()
Book(title='The Hitchhikers Guide to the Galaxy', author='Douglas Adams', year=1979)
>>> Book.__dict__["__init__"]
<function Book.__init__ at ...>
```

The gathering of field and class information is also separated from the build step
so it is possible to change how this information is gathered without needing to rewrite
the code generation tools.

## The base class `Prefab` implementation ##

Alongside the `@prefab` decorator there is also a `Prefab` base class that can be used.

The main differences in behaviour are that `Prefab` will generate `__slots__` by default
using a metaclass, and any options given to `Prefab` will automatically be set on subclasses.

Unlike attrs' `@define` or dataclasses' `@dataclass`, `@prefab` does not and will not support
`__slots__` (this is explained in a section below).

```python
from pathlib import Path
from ducktools.classbuilder.prefab import Prefab, attribute

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

ex = Slotted()
print(ex)
print(ex.__slots__)
```

The generated source code for the methods can be viewed using the `print_generated_code` helper function.
The generated code for the methods can be viewed using the `print_generated_code` helper function.

<details>

<summary>Generated source code for the same example, but with all optional methods enabled</summary>

```python
from ducktools.classbuilder import print_generated_code
Source:
def __delattr__(self, name):
raise TypeError(
f"{type(self).__name__!r} object "
f"does not support attribute deletion"
)

def __eq__(self, other):
return (
self.the_answer == other.the_answer
and self.the_question == other.the_question
and self.python_path == other.python_path
) if self.__class__ is other.__class__ else NotImplemented

def __ge__(self, other):
if self.__class__ is other.__class__:
return (self.the_answer, self.the_question, self.python_path) >= (other.the_answer, other.the_question, other.python_path)
return NotImplemented

def __gt__(self, other):
if self.__class__ is other.__class__:
return (self.the_answer, self.the_question, self.python_path) > (other.the_answer, other.the_question, other.python_path)
return NotImplemented

def __hash__(self):
return hash((self.the_answer, self.the_question, self.python_path))

def __init__(self, the_answer=42, the_question='What do you get if you multiply six by nine?', python_path=_python_path_default):
self.the_answer = the_answer
self.the_question = the_question
self.python_path = python_path

def __iter__(self):
yield self.the_answer
yield self.the_question
yield self.python_path

def __le__(self, other):
if self.__class__ is other.__class__:
return (self.the_answer, self.the_question, self.python_path) <= (other.the_answer, other.the_question, other.python_path)
return NotImplemented

def __lt__(self, other):
if self.__class__ is other.__class__:
return (self.the_answer, self.the_question, self.python_path) < (other.the_answer, other.the_question, other.python_path)
return NotImplemented

def __replace__(self, /, **changes):
new_kwargs = {'the_answer': self.the_answer, 'the_question': self.the_question, 'python_path': self.python_path}
new_kwargs |= changes
return self.__class__(**new_kwargs)

@_recursive_repr
def __repr__(self):
return f'{type(self).__qualname__}(the_answer={self.the_answer!r}, the_question={self.the_question!r}, python_path={self.python_path!r})'

def __setattr__(self, name, value):
if hasattr(self, name) or name not in __field_names:
raise TypeError(
f"{type(self).__name__!r} object does not support "
f"attribute assignment"
)
else:
__setattr_func(self, name, value)

def as_dict(self):
return {'the_answer': self.the_answer, 'the_question': self.the_question, 'python_path': self.python_path}


Globals:
__init__: {'_python_path_default': PosixPath('/usr/bin/python')}
__repr__: {'_recursive_repr': <function recursive_repr.<locals>.decorating_function at 0x7367f9cddf30>}
__setattr__: {'__field_names': {'the_question', 'the_answer', 'python_path'}, '__setattr_func': <slot wrapper '__setattr__' of 'object' objects>}

Annotations:
__init__: {'the_answer': <class 'int'>, 'the_question': <class 'str'>, 'python_path': <class 'pathlib.Path'>, 'return': None}

print_generated_code(Slotted)
```

</details>

### Core ###

The base `ducktools.classbuilder` module provides tools for creating a customized version of the `dataclass` concept.
The main `ducktools.classbuilder` module provides tools for creating a customized version of the `dataclass` concept.

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

Expand All @@ -76,12 +218,18 @@ The base `ducktools.classbuilder` module provides tools for creating a customize

### Prefab ###

This prebuilt implementation is available from the `ducktools.classbuilder.prefab` submodule.
The prebuilt 'prefab' implementation includes additional customization including
`__prefab_pre_init__` and `__prefab_post_init__` methods.

Both of these methods will take any field names as arguments. Those passed to `__prefab_pre_init__` will still be set
inside the main `__init__` body, while those passed to `__prefab_post_init__` will not.

`__prefab_pre_init__` is intended as a place to perform validation checks before values are set in the main body.
`__prefab_post_init__` can be seen as a partial `__init__` function, where you only need to write
the `__init__` function for arguments that need more than basic assignment.

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

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

#### Features ####
<details>

<summary>The generated code for the init method</summary>

```python
def __init__(self, app_name, app_path):
self.app_name = app_name
self.__prefab_post_init__(app_path=app_path)
```

Note: annotations are attached as `__annotations__` and so do not appear in generated
source code.

</details>

#### Features and Differences ####

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

* All standard methods are generated on-demand
* This makes the construction of classes much faster in general
* Generation is done and then cached on first access
* Generation is done and then cached on first access using non-data descriptors
* Standard `__init__`, `__eq__` and `__repr__` methods are generated by default
- The `__repr__` implementation does not automatically protect against recursion,
but there is a `recursive_repr` argument that will do so if needed
Expand Down Expand Up @@ -155,7 +318,9 @@ There are also some intentionally missing features:
* `VALUE` annotations are used as they are faster in most cases
* As the `__init__` method gets `__annotations__` these need to be either values or strings
to match the behaviour of previous Python versions

* There is currently no equivalent to `InitVar`
* I'm not sure *how* I would want to implement this other than I don't _really_ want to use
annotations to decide behaviour (this is messy enough with `ClassVar` and `KW_ONLY`).

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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ docs = [
dev = [
"pytest>=8.4",
"pytest-cov>=6.1",
"mypy>=1.16",
"mypy>=1.16; platform_python_implementation == 'CPython'",
"typing-extensions>=4.14",
]
performance = [
Expand Down
15 changes: 15 additions & 0 deletions scripts/source_code_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pathlib import Path

from ducktools.classbuilder import print_generated_code
from ducktools.classbuilder.prefab import Prefab, attribute


class Example(Prefab, order=True, iter=True, frozen=True, dict_method=True, recursive_repr=True):
the_answer: int = 42
the_question: str = attribute(
default="What do you get if you multiply six by nine?",
doc="Life the universe and everything",
)
python_path: Path = Path("/usr/bin/python")

print_generated_code(Example)
17 changes: 16 additions & 1 deletion src/ducktools/classbuilder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ def frozen_setattr_generator(cls, funcname="__setattr__"):
body = (
f" if {hasattr_check} or name not in __field_names:\n"
f' raise TypeError(\n'
f' f"{{type(self).__name__!r}} object does not support "'
f' f"{{type(self).__name__!r}} object does not support "\n'
f' f"attribute assignment"\n'
f' )\n'
f" else:\n"
Expand All @@ -583,6 +583,20 @@ def frozen_delattr_generator(cls, funcname="__delattr__"):
return GeneratedCode(code, globs)


def hash_generator(cls, funcname="__hash__"):
fields = get_fields(cls)
vals = ", ".join(
f"self.{name}"
for name, attrib in fields.items()
if attrib.compare
)
if len(fields) == 1:
vals += ","
code = f"def {funcname}(self):\n return hash(({vals}))\n"
globs = {}
return GeneratedCode(code, globs)


# As only the __get__ method refers to the class we can use the same
# Descriptor instances for every class.
init_maker = MethodMaker("__init__", init_generator)
Expand All @@ -595,6 +609,7 @@ def frozen_delattr_generator(cls, funcname="__delattr__"):
replace_maker = MethodMaker("__replace__", replace_generator)
frozen_setattr_maker = MethodMaker("__setattr__", frozen_setattr_generator)
frozen_delattr_maker = MethodMaker("__delattr__", frozen_delattr_generator)
hash_maker = MethodMaker("__hash__", hash_generator)
default_methods = frozenset({init_maker, repr_maker, eq_maker})

# Special `__init__` maker for 'Field' subclasses - needs its own NOTHING option
Expand Down
3 changes: 2 additions & 1 deletion src/ducktools/classbuilder/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ def ge_generator(cls: type, funcname: str = ...) -> GeneratedCode: ...
def replace_generator(cls: type, funcname: str = "__replace__") -> GeneratedCode: ...

def frozen_setattr_generator(cls: type, funcname: str = "__setattr__") -> GeneratedCode: ...

def frozen_delattr_generator(cls: type, funcname: str = "__delattr__") -> GeneratedCode: ...
def hash_generator(cls: type, funcname: str = ...) -> GeneratedCode: ...

init_maker: MethodMaker
repr_maker: MethodMaker
Expand All @@ -136,6 +136,7 @@ ge_maker: MethodMaker
replace_maker: MethodMaker
frozen_setattr_maker: MethodMaker
frozen_delattr_maker: MethodMaker
hash_maker: MethodMaker
default_methods: frozenset[MethodMaker]

_TypeT = typing.TypeVar("_TypeT", bound=type)
Expand Down
Loading