Skip to content

Instantly share code, notes, and snippets.

@mikeholler
Created January 8, 2020 21:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mikeholler/4be180627d3f8fceb55704b729464adb to your computer and use it in GitHub Desktop.
Save mikeholler/4be180627d3f8fceb55704b729464adb to your computer and use it in GitHub Desktop.
Require keyword or positional arguments for Python dataclasses
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
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