Skip to content

Commit 46525f7

Browse files
committed
Add a typing module with a decorator to disable __init__
This commit adds a new module `frequenz.core.typing` with a decorator `disable_init` that can be used to disable the `__init__` constructor of a class. This is useful when a class doesn't provide a default constructor and requires the use of a factory method to create instances. The code is loosely based on the `_NoDefaultConstructible` class in the SDK [`frequenz.sdk.timeseries._quantities`][1], but it was completely rewritten to be more generic and to use a decorator instead of inheritance, which makes also much easier to use. ```python @disable_init class MyClass(Super): pass ``` Versus: ```python class MyClass( Super, metaclass=_NoDefaultConstructible, ): pass ``` Also it is safer to use, as it will raise an error as soon as a class is declared with an `__init__` method. [1]: https://github.com/frequenz-floss/frequenz-sdk-python/blob/88bd28e2f21b930279d66c1daf3deeaf6f9bb07a/src/frequenz/sdk/timeseries/_quantities.py#L505-L522 Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 7afcf3a commit 46525f7

File tree

2 files changed

+413
-0
lines changed

2 files changed

+413
-0
lines changed

src/frequenz/core/typing.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Type hints and utility functions for type checking and types.
5+
6+
For now this module only provides a decorator to disable the `__init__` constructor of
7+
a class, to force the use of a factory method to create instances. See
8+
[disable_init][frequenz.core.typing.disable_init] for more information.
9+
"""
10+
11+
from collections.abc import Callable
12+
from typing import Any, NoReturn, TypeVar, cast, overload
13+
14+
TypeT = TypeVar("TypeT", bound=type)
15+
"""A type variable that is bound to a type."""
16+
17+
18+
@overload
19+
def disable_init(
20+
cls: None = None,
21+
*,
22+
error: Exception | None = None,
23+
) -> Callable[[TypeT], TypeT]: ...
24+
25+
26+
@overload
27+
def disable_init(cls: TypeT) -> TypeT: ...
28+
29+
30+
def disable_init(
31+
cls: TypeT | None = None,
32+
*,
33+
error: Exception | None = None,
34+
) -> TypeT | Callable[[TypeT], TypeT]:
35+
"""Disable the `__init__` constructor of a class.
36+
37+
This decorator can be used to disable the `__init__` constructor of a class. It is
38+
intended to be used with classes that don't provide a default constructor and
39+
require the use of a factory method to create instances.
40+
41+
When marking a class with this decorator, the class cannot be even declared with a
42+
`__init__` method, as it will raise a `TypeError` when the class is created, as soon
43+
as the class is parsed by the Python interpreter. It will also raise a `TypeError`
44+
when the `__init__` method is called.
45+
46+
To create an instance you must provide a factory method, using `__new__`.
47+
48+
Warning:
49+
This decorator will use a custom metaclass to disable the `__init__` constructor
50+
of the class, so if your class already uses a custom metaclass, you should be
51+
aware of potential conflicts.
52+
53+
Example: Basic example defining a class with a factory method
54+
To be able to type hint the class correctly, you can declare the instance
55+
attributes in the class body, and then use a factory method to create instances.
56+
57+
```python
58+
from typing import Self
59+
60+
@disable_init
61+
class MyClass:
62+
value: int
63+
64+
@classmethod
65+
def new(cls, value: int = 1) -> Self:
66+
self = cls.__new__(cls)
67+
self.value = value
68+
return self
69+
70+
instance = MyClass.new()
71+
72+
# Calling the default constructor (__init__) will raise a TypeError
73+
try:
74+
instance = MyClass()
75+
except TypeError as e:
76+
print(e)
77+
```
78+
79+
Example: Class wrongly providing an `__init__` constructor
80+
```python
81+
try:
82+
@disable_init
83+
class MyClass:
84+
def __init__(self) -> None:
85+
pass
86+
except TypeError as e:
87+
assert isinstance(e, TypeError)
88+
print(e)
89+
```
90+
91+
Example: Using a custom error message when the default constructor is called
92+
```python
93+
from typing import Self
94+
95+
class NoInitError(TypeError):
96+
def __init__(self) -> None:
97+
super().__init__("Please create instances of MyClass using MyClass.new()")
98+
99+
@disable_init(error=NoInitError())
100+
class MyClass:
101+
@classmethod
102+
def new(cls) -> Self:
103+
return cls.__new__(cls)
104+
105+
try:
106+
instance = MyClass()
107+
except NoInitError as e:
108+
assert str(e) == "Please create instances of MyClass using MyClass.new()"
109+
print(e)
110+
```
111+
112+
Args:
113+
cls: The class to be decorated.
114+
error: The error to raise if __init__ is called, if `None` a default
115+
[TypeError][] will be raised.
116+
117+
Returns:
118+
A decorator that disables the `__init__` constructor of `cls`.
119+
"""
120+
121+
def decorator(inner_cls: TypeT) -> TypeT:
122+
return cast(
123+
TypeT,
124+
_NoInitConstructibleMeta(
125+
inner_cls.__name__,
126+
inner_cls.__bases__,
127+
dict(inner_cls.__dict__),
128+
no_init_constructible_error=error,
129+
),
130+
)
131+
132+
if cls is None:
133+
return decorator
134+
return decorator(cls)
135+
136+
137+
class _NoInitConstructibleMeta(type):
138+
"""A metaclass that disables the __init__ constructor."""
139+
140+
def __new__(
141+
mcs,
142+
name: str,
143+
bases: tuple[type, ...],
144+
namespace: dict[str, Any],
145+
**kwargs: Any,
146+
) -> type:
147+
"""Create a new class with a disabled __init__ constructor.
148+
149+
Args:
150+
name: The name of the new class.
151+
bases: The base classes of the new class.
152+
namespace: The namespace of the new class.
153+
**kwargs: Additional keyword arguments.
154+
155+
Returns:
156+
The new class with a disabled __init__ constructor.
157+
158+
Raises:
159+
TypeError: If the class provides a default constructor.
160+
"""
161+
if "__init__" in namespace:
162+
raise _get_no_init_constructible_error(name, bases, kwargs)
163+
return super().__new__(mcs, name, bases, namespace)
164+
165+
def __init__(
166+
cls,
167+
name: str,
168+
bases: tuple[type, ...],
169+
namespace: dict[str, Any],
170+
**kwargs: Any,
171+
) -> None:
172+
"""Initialize the new class."""
173+
super().__init__(name, bases, namespace)
174+
cls._no_init_constructible_error = kwargs.get("no_init_constructible_error")
175+
176+
def __call__(cls, *args: Any, **kwargs: Any) -> NoReturn:
177+
"""Raise an error when the __init__ constructor is called.
178+
179+
Args:
180+
*args: ignored positional arguments.
181+
**kwargs: ignored keyword arguments.
182+
183+
Raises:
184+
TypeError: Always.
185+
"""
186+
raise _get_no_init_constructible_error(
187+
cls.__name__,
188+
cls.__bases__,
189+
{"no_init_constructible_error": cls._no_init_constructible_error},
190+
)
191+
192+
193+
def _get_no_init_constructible_error(
194+
name: str, bases: tuple[type, ...], kwargs: Any
195+
) -> Exception:
196+
error = kwargs.get("no_init_constructible_error")
197+
if error is None:
198+
for base in bases:
199+
if attr := getattr(base, "_no_init_constructible_error", None):
200+
error = attr
201+
break
202+
else:
203+
error = TypeError(
204+
f"{name} doesn't provide a default constructor, you must use a "
205+
"factory method to create instances."
206+
)
207+
assert isinstance(error, Exception)
208+
return error

0 commit comments

Comments
 (0)