From a07d52bb3317a396af954230de6d23af01797171 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 24 Mar 2025 12:21:18 +0100 Subject: [PATCH 1/4] Pass keyword arguments as such Signed-off-by: Leandro Lucarella --- src/frequenz/core/typing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/frequenz/core/typing.py b/src/frequenz/core/typing.py index 428d4cc..c152957 100644 --- a/src/frequenz/core/typing.py +++ b/src/frequenz/core/typing.py @@ -165,7 +165,7 @@ def __new__( # noqa: DOC503 TypeError: If the class provides a default constructor. """ if "__init__" in namespace: - raise _get_no_init_constructible_error(name, bases, kwargs) + raise _get_no_init_constructible_error(name, bases, **kwargs) return super().__new__(mcs, name, bases, namespace) def __init__( @@ -194,12 +194,12 @@ def __call__(cls, *args: Any, **kwargs: Any) -> NoReturn: # noqa: DOC503 raise _get_no_init_constructible_error( cls.__name__, cls.__bases__, - {"no_init_constructible_error": cls._no_init_constructible_error}, + no_init_constructible_error=cls._no_init_constructible_error, ) def _get_no_init_constructible_error( - name: str, bases: tuple[type, ...], kwargs: Any + name: str, bases: tuple[type, ...], **kwargs: Any ) -> Exception: error = kwargs.get("no_init_constructible_error") if error is None: From 589656a48a0afbdee6db7dca41d9b17dcccc5308 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 24 Mar 2025 12:21:55 +0100 Subject: [PATCH 2/4] Rename `_NoInitConstructibleMeta` to `NoInitConstructibleMeta` This effectively make it public. We need this in case users want to combine the metaclass with other metaclasses, like `abc.ABCMeta`. Signed-off-by: Leandro Lucarella --- src/frequenz/core/typing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frequenz/core/typing.py b/src/frequenz/core/typing.py index c152957..b031f8f 100644 --- a/src/frequenz/core/typing.py +++ b/src/frequenz/core/typing.py @@ -125,7 +125,7 @@ def new(cls) -> Self: def decorator(inner_cls: TypeT) -> TypeT: return cast( TypeT, - _NoInitConstructibleMeta( + NoInitConstructibleMeta( inner_cls.__name__, inner_cls.__bases__, dict(inner_cls.__dict__), @@ -138,7 +138,7 @@ def decorator(inner_cls: TypeT) -> TypeT: return decorator(cls) -class _NoInitConstructibleMeta(type): +class NoInitConstructibleMeta(type): """A metaclass that disables the __init__ constructor.""" # We need to use noqa here because pydoclint can't figure out that From 60599edbfbb2086e2371706a1ed35e4caae0391f Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 24 Mar 2025 12:36:14 +0100 Subject: [PATCH 3/4] Revert "Improve documentation of `disable_init` decorator" The new recommendation actually doesn't work. The correct way to do it would be: ```py super(cls, self).__init__() ``` But this is not even necessary when you are applying the decorator to a simple class that doesn't inherit from anything (i.e. inherits from `object` directly). Applying the decorators to other sub-classes, is much trickier than just addint the `super()` call, so we will just not recommend or support doing that and keep the usage documentation simple. This reverts commit 073b9216ee2c7c1051494168a01298b14a32dc7b. Signed-off-by: Leandro Lucarella --- src/frequenz/core/typing.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/frequenz/core/typing.py b/src/frequenz/core/typing.py index b031f8f..2c2959a 100644 --- a/src/frequenz/core/typing.py +++ b/src/frequenz/core/typing.py @@ -43,8 +43,7 @@ def disable_init( as the class is parsed by the Python interpreter. It will also raise a `TypeError` when the `__init__` method is called. - To create an instance you must provide a factory method, using `__new__` to create - the instance and calling `super().__init__(self)` explicitly to initialize it. + To create an instance you must provide a factory method, using `__new__`. Warning: This decorator will use a custom metaclass to disable the `__init__` constructor @@ -65,7 +64,6 @@ class MyClass: @classmethod def new(cls, value: int = 1) -> Self: self = cls.__new__(cls) - super().__init__(self) self.value = value return self @@ -102,9 +100,7 @@ def __init__(self) -> None: class MyClass: @classmethod def new(cls) -> Self: - self = cls.__new__(cls) - super().__init__(self) - return self + return cls.__new__(cls) try: instance = MyClass() From cb4c104e3841f2083d8d0e27f09b1d8418cd73ce Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 24 Mar 2025 12:55:50 +0100 Subject: [PATCH 4/4] typing: Improve documentation Improve the docs to point out that disabling the `__init__` for existing classes via a sub-class is complicated, so it is only recommended to use both the decorator and metaclass on classes inheriting from `object` directly. Also improve the documentation of `NoInitConstructibleMeta` to be more complete and give an example on using with `abc.ABCMeta`. Signed-off-by: Leandro Lucarella --- src/frequenz/core/typing.py | 109 ++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/src/frequenz/core/typing.py b/src/frequenz/core/typing.py index 2c2959a..efc6b76 100644 --- a/src/frequenz/core/typing.py +++ b/src/frequenz/core/typing.py @@ -3,9 +3,14 @@ """Type hints and utility functions for type checking and types. -For now this module only provides a decorator to disable the `__init__` constructor of -a class, to force the use of a factory method to create instances. See -[disable_init][frequenz.core.typing.disable_init] for more information. +This module provides a decorator to disable the `__init__` constructor of a class, to +force the use of a factory method to create instances. See +[`@disable_init`][frequenz.core.typing.disable_init] for more information. + +It also provides a metaclass used by the decorator to disable the `__init__`: +[`NoInitConstructibleMeta`][frequenz.core.typing.NoInitConstructibleMeta]. This is +useful mostly for disabling `__init__` while having to use another metaclass too (like +[`abc.ABCMeta`][abc.ABCMeta]). """ from collections.abc import Callable @@ -48,7 +53,14 @@ def disable_init( Warning: This decorator will use a custom metaclass to disable the `__init__` constructor of the class, so if your class already uses a custom metaclass, you should be - aware of potential conflicts. + aware of potential conflicts. See + [`NoInitConstructibleMeta`][frequenz.core.typing.NoInitConstructibleMeta] for an + example on how to use more than one metaclass. + + It is also recommended to apply this decorator only to classes inheriting from + `object` directly (i.e. not explicitly inheriting from any other classes), as + things can also get tricky when applying the constructor to a sub-class for the + first time. Example: Basic example defining a class with a factory method To be able to type hint the class correctly, you can declare the instance @@ -135,7 +147,94 @@ def decorator(inner_cls: TypeT) -> TypeT: class NoInitConstructibleMeta(type): - """A metaclass that disables the __init__ constructor.""" + """A metaclass that disables the `__init__` constructor. + + This metaclass can be used to disable the `__init__` constructor of a class. It is + intended to be used with classes that don't provide a default constructor and + require the use of a factory method to create instances. + + When marking a class using this metaclass, the class cannot be even declared with a + `__init__` method, as it will raise a `TypeError` when the class is created, as soon + as the class is parsed by the Python interpreter. It will also raise a `TypeError` + when the `__init__` method is called. + + To create an instance you must provide a factory method, using `__new__`. + + Warning: + It is also recommended to apply this metaclass only to classes inheriting from + `object` directly (i.e. not explicitly inheriting from any other classes), as + things can also get tricky when applying the constructor to a sub-class for the + first time. + + Example: Basic example defining a class with a factory method + To be able to type hint the class correctly, you can declare the instance + attributes in the class body, and then use a factory method to create instances. + + ```python + from typing import Self + + class MyClass(metaclass=NoInitConstructibleMeta): + value: int + + @classmethod + def new(cls, value: int = 1) -> Self: + self = cls.__new__(cls) + self.value = value + return self + + instance = MyClass.new() + + # Calling the default constructor (__init__) will raise a TypeError + try: + instance = MyClass() + except TypeError as e: + print(e) + ``` + + Hint: + The [`@disable_init`][frequenz.core.typing.disable_init] decorator is a more + convenient way to use this metaclass. + + Example: Example combining with other metaclass + A typical case where you might want this is to combine with + [`abc.ABCMeta`][abc.ABCMeta] to create an abstract class that doesn't provide a + default constructor. + + ```python + from abc import ABCMeta, abstractmethod + from typing import Self + + class NoInitConstructibleABCMeta(ABCMeta, NoInitConstructibleMeta): + pass + + class MyAbstractClass(metaclas=NoInitConstructibleABCMeta): + @abstractmethod + def do_something(self) -> None: + ... + + class MyClass(MyAbstractClass): + value: int + + @classmethod + def new(cls, value: int = 1) -> Self: + self = cls.__new__(cls) + self.value = value + return self + + def do_something(self) -> None: + print("Doing something") + + instance = MyClass.new() + instance.do_something() + + # Calling the default constructor (__init__) will raise a TypeError + try: + instance = MyClass() + except TypeError as e: + print(e) + ``` + + """ # We need to use noqa here because pydoclint can't figure out that # _get_no_init_constructible_error() returns a TypeError.