Created
January 8, 2020 21:25
-
-
Save mikeholler/4be180627d3f8fceb55704b729464adb to your computer and use it in GitHub Desktop.
Require keyword or positional arguments for Python dataclasses
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from dataclasses import is_dataclass | |
from typing import TypeVar, Type, Callable, List, Dict, Any | |
_T = TypeVar("_T") | |
_Self = TypeVar("_Self") | |
_VarArgs = List[Any] | |
_KWArgs = Dict[str, Any] | |
def _kwarg_only_init_wrapper( | |
self: _Self, | |
init: Callable[..., None], | |
*args: _VarArgs, | |
**kwargs: _KWArgs | |
) -> None: | |
if len(args) > 0: | |
raise TypeError( | |
f"{type(self).__name__}.__init__(self, ...) only allows keyword arguments. Found the " | |
f"following positional arguments: {args}" | |
) | |
init(self, **kwargs) | |
def _positional_arg_only_init_wrapper( | |
self: _Self, | |
init: Callable[..., None], | |
*args: _VarArgs, | |
**kwargs: _KWArgs | |
) -> None: | |
if len(kwargs) > 0: | |
raise TypeError( | |
f"{type(self).__name__}.__init__(self, ...) only allows positional arguments. Found " | |
f"the following keyword arguments: {kwargs}" | |
) | |
init(self, *args) | |
def require_kwargs_on_init(cls: Type[_T]) -> Type[_T]: | |
""" | |
Force a dataclass's init function to only work if called with keyword arguments. | |
If parameters are not positional-only, a TypeError is thrown with a helpful message. | |
This function may only be used on dataclasses. | |
This works by wrapping the __init__ function and dynamically replacing it. Therefore, | |
stacktraces for calls to the new __init__ might look a bit strange. Fear not though, | |
all is well. | |
Note: although this may be used as a decorator, this is not advised as IDEs will no longer | |
suggest parameters in the constructor. Instead, this is the recommended usage:: | |
from dataclasses import dataclass | |
@dataclass | |
class Foo: | |
bar: str | |
require_kwargs_on_init(Foo) | |
""" | |
if cls is None: | |
raise TypeError("Cannot call with cls=None") | |
if not is_dataclass(cls): | |
raise TypeError( | |
f"This decorator only works on dataclasses. {cls.__name__} is not a dataclass." | |
) | |
original_init = cls.__init__ | |
def new_init(self: _Self, *args: _VarArgs, **kwargs: _KWArgs) -> None: | |
_kwarg_only_init_wrapper(self, original_init, *args, **kwargs) | |
# noinspection PyTypeHints | |
cls.__init__ = new_init # type: ignore | |
return cls | |
def require_positional_args_on_init(cls: Type[_T]) -> Type[_T]: | |
""" | |
Force a dataclass's init function to only work if called with positional arguments. | |
If parameters are not positional-only, a TypeError is thrown with a helpful message. | |
This function may only be used on dataclasses. | |
This works by wrapping the __init__ function and dynamically replacing it. Therefore, | |
stacktraces for calls to the new __init__ might look a bit strange. Fear not though, | |
all is well. | |
Note: although this may be used as a decorator, this is not advised as IDEs will no longer | |
suggest parameters in the constructor. Instead, this is the recommended usage:: | |
from dataclasses import dataclass | |
@dataclass | |
class Foo: | |
bar: str | |
require_positional_args_on_init(Foo) | |
""" | |
if cls is None: | |
raise TypeError("Cannot call with cls=None") | |
if not is_dataclass(cls): | |
raise TypeError( | |
f"This decorator only works on dataclasses. {cls.__name__} is not a dataclass." | |
) | |
original_init = cls.__init__ | |
def new_init(self: _Self, *args: _VarArgs, **kwargs: _KWArgs) -> None: | |
_positional_arg_only_init_wrapper(self, original_init, *args, **kwargs) | |
# noinspection PyTypeHints | |
cls.__init__ = new_init # type: ignore | |
return cls |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import unittest | |
import re | |
import dataclass_utils as d | |
from dataclasses import dataclass | |
class TestRequireKwargsOnInit(unittest.TestCase): | |
def test_used_as_decorator(self): | |
@d.require_kwargs_on_init | |
@dataclass | |
class Foo: | |
bar: str | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape( | |
"Foo.__init__(self, ...) only allows keyword arguments. Found the following " | |
"positional arguments: ('bar-value',)" | |
) | |
): | |
Foo("bar-value") | |
def test_instance_created_with_positional_only(self): | |
@dataclass | |
class Foo: | |
bar: str | |
d.require_kwargs_on_init(Foo) | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape( | |
"Foo.__init__(self, ...) only allows keyword arguments. Found the following " | |
"positional arguments: ('bar-value',)" | |
) | |
): | |
Foo("bar-value") | |
def test_instance_created_with_mix(self): | |
@dataclass | |
class Foo: | |
bar: str | |
baz: str | |
d.require_kwargs_on_init(Foo) | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape( | |
"Foo.__init__(self, ...) only allows keyword arguments. Found the following " | |
"positional arguments: ('bar-value',)" | |
) | |
): | |
Foo("bar-value", baz="baz-value") | |
def test_instance_created_correctly(self): | |
@dataclass | |
class Foo: | |
bar: str | |
baz: str | |
d.require_kwargs_on_init(Foo) | |
result = Foo(bar="bar-value", baz="baz-value") | |
self.assertEqual(result.bar, "bar-value") | |
self.assertEqual(result.baz, "baz-value") | |
def test_on_custom_init(self): | |
@dataclass(init=False) | |
class Foo: | |
def __init__(self, bar: str, baz: str): | |
self.bar = bar | |
self.baz = baz | |
bar: str | |
baz: str | |
d.require_kwargs_on_init(Foo) | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape( | |
"Foo.__init__(self, ...) only allows keyword arguments. Found the following " | |
"positional arguments: ('bar-value',)" | |
) | |
): | |
Foo("bar-value", baz="baz-value") | |
def test_used_on_non_dataclass(self): | |
class Foo: | |
def __init__(self, bar: str, baz: str): | |
self.bar = bar | |
self.baz = baz | |
bar: str | |
baz: str | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape("This decorator only works on dataclasses. Foo is not a dataclass.") | |
): | |
d.require_kwargs_on_init(Foo) | |
def test_used_on_non_dataclass_as_decorator(self): | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape("This decorator only works on dataclasses. Foo is not a dataclass.") | |
): | |
@d.require_kwargs_on_init | |
class Foo: | |
def __init__(self, bar: str, baz: str): | |
self.bar = bar | |
self.baz = baz | |
bar: str | |
baz: str | |
def test_used_on_none(self): | |
with self.assertRaisesRegex(TypeError, "Cannot call with cls=None"): | |
d.require_kwargs_on_init(None) | |
def test_returns_dataclass(self): | |
@dataclass | |
class Foo: | |
bar: str | |
result = d.require_kwargs_on_init(Foo) | |
self.assertIs(Foo, result) | |
class TestRequirePositionalArgsOnInit(unittest.TestCase): | |
def test_used_as_decorator(self): | |
@d.require_positional_args_on_init | |
@dataclass | |
class Foo: | |
bar: str | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape( | |
"Foo.__init__(self, ...) only allows positional arguments. Found the " | |
"following keyword arguments: {'bar': 'bar-value'}" | |
) | |
): | |
Foo(bar="bar-value") | |
def test_instance_created_with_keyword_only(self): | |
@dataclass | |
class Foo: | |
bar: str | |
d.require_positional_args_on_init(Foo) | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape( | |
"Foo.__init__(self, ...) only allows positional arguments. Found the " | |
"following keyword arguments: {'bar': 'bar-value'}" | |
) | |
): | |
Foo(bar="bar-value") | |
def test_instance_created_with_mix(self): | |
@dataclass | |
class Foo: | |
bar: str | |
baz: str | |
d.require_positional_args_on_init(Foo) | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape( | |
"Foo.__init__(self, ...) only allows positional arguments. Found the " | |
"following keyword arguments: {'bar': 'bar-value', 'baz': 'baz-value'}" | |
) | |
): | |
Foo(bar="bar-value", baz="baz-value") | |
def test_instance_created_correctly(self): | |
@dataclass | |
class Foo: | |
bar: str | |
baz: str | |
d.require_positional_args_on_init(Foo) | |
result = Foo("bar-value", "baz-value") | |
self.assertEqual(result.bar, "bar-value") | |
self.assertEqual(result.baz, "baz-value") | |
def test_on_custom_init(self): | |
@dataclass(init=False) | |
class Foo: | |
def __init__(self, bar: str, baz: str): | |
self.bar = bar | |
self.baz = baz | |
bar: str | |
baz: str | |
d.require_positional_args_on_init(Foo) | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape( | |
"Foo.__init__(self, ...) only allows positional arguments. Found the " | |
"following keyword arguments: {'baz': 'baz-value'}" | |
) | |
): | |
Foo("bar-value", baz="baz-value") | |
def test_used_on_non_dataclass(self): | |
class Foo: | |
def __init__(self, bar: str, baz: str): | |
self.bar = bar | |
self.baz = baz | |
bar: str | |
baz: str | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape("This decorator only works on dataclasses. Foo is not a dataclass.") | |
): | |
d.require_positional_args_on_init(Foo) | |
def test_used_on_non_dataclass_as_decorator(self): | |
with self.assertRaisesRegex( | |
TypeError, | |
re.escape("This decorator only works on dataclasses. Foo is not a dataclass.") | |
): | |
@d.require_positional_args_on_init | |
class Foo: | |
def __init__(self, bar: str, baz: str): | |
self.bar = bar | |
self.baz = baz | |
bar: str | |
baz: str | |
def test_used_on_none(self): | |
with self.assertRaisesRegex(TypeError, "Cannot call with cls=None"): | |
d.require_positional_args_on_init(None) | |
def test_returns_dataclass(self): | |
@dataclass | |
class Foo: | |
bar: str | |
result = d.require_positional_args_on_init(Foo) | |
self.assertIs(Foo, result) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment