diff --git a/README.md b/README.md index b053c88..468a8d3 100755 --- a/README.md +++ b/README.md @@ -229,6 +229,9 @@ If true, force the generation of a [`__hash__`](https://docs.python.org/3/refere ##### `order` If true, a [`__lt__`](https://docs.python.org/3/reference/datamodel.html#object.__lt__) method is generated, making the class *orderable*. If `eq` is also true, all other comparison methods are also generated. These methods compare this data class to another of the same type (or a subclass) as if they were tuples created by [`as_tuple`](#as_tupledataclass). The normal rules of [lexicographical comparison](https://docs.python.org/3/reference/expressions.html#value-comparisons) apply. +##### `kw_only` +If true, all parameters to the generated `__init__` are marked as **keyword-only**. This includes arguments passed through to `__post_init__`. + ##### `iter` If true, generate an [`__iter__`](https://docs.python.org/3/reference/datamodel.html#object.__iter__) method that returns the values of the class's fields, in order of definition. This can be used to destructure a data class instance, as with a Scala `case class` or a Python `namedtuple`. diff --git a/dataclassy/dataclass.py b/dataclassy/dataclass.py index 9791a2f..4571222 100644 --- a/dataclassy/dataclass.py +++ b/dataclassy/dataclass.py @@ -58,8 +58,8 @@ def factory(producer: Callable[[], Factory.Produces]) -> Factory.Produces: class DataClassMeta(type): """The metaclass that implements data class behaviour.""" - DEFAULT_OPTIONS = dict(init=True, repr=True, eq=True, iter=False, frozen=False, order=False, unsafe_hash=False, - kwargs=False, slots=False, hide_internals=True) + DEFAULT_OPTIONS = dict(init=True, repr=True, eq=True, frozen=False, order=False, unsafe_hash=False, kw_only=False, + iter=False, kwargs=False, slots=False, hide_internals=True) def __new__(mcs, name, bases, dict_, **kwargs): """Create a new data class.""" @@ -166,13 +166,15 @@ def is_user_func(obj: Any, object_methods=frozenset(vars(object).values())) -> b def generate_init(annotations: Dict, defaults: Dict, options: Dict, user_init: bool) -> Function: """Generate and return an __init__ method for a data class. This method has as parameters all fields of the data class. When the data class is initialised, arguments to this function are applied to the fields of the new instance. - A user-defined __init__, if present, must be aliased to avoid conflicting.""" + Finally, the generated method will call __post_init__ with leftover arguments, if it is defined.""" + kw_only = ['*'] if options['kw_only'] else [] arguments = [a for a in annotations if a not in defaults] default_arguments = [f'{a}={a}' for a in defaults] - args = ['*args'] if user_init else [] + args = ['*args'] if user_init and not options['kw_only'] else [] kwargs = ['**kwargs'] if user_init or options['kwargs'] else [] - parameters = ', '.join(arguments + default_arguments + args + kwargs) + parameters = ', '.join(kw_only + arguments + default_arguments + args + kwargs) + call_post_init = f"self.__post_init__({', '.join(args + kwargs)})" if user_init else '' # surprisingly, given global lookups are slow, using them is the fastest way to compare a field to its default # the alternatives are to look up on self (which wouldn't work when slots=True) or look up self.__defaults__ @@ -187,9 +189,7 @@ def generate_init(annotations: Dict, defaults: Dict, options: Dict, user_init: b else f'self.{n} = {r}' for n, r in references.items()] # generate the function - lines = [f'def __init__(self, {parameters}):', - *assignments, - 'self.__post_init__(*args, **kwargs)' if user_init else ''] + lines = [f'def __init__(self, {parameters}):', *assignments, call_post_init] return eval_function('__init__', lines, annotations, defaults, default_names) diff --git a/dataclassy/decorator.py b/dataclassy/decorator.py index d99ef6e..8c77d6e 100644 --- a/dataclassy/decorator.py +++ b/dataclassy/decorator.py @@ -25,6 +25,7 @@ def dataclass(cls: Optional[type] = None, *, meta=DataClassMeta, **options) -> T :key order: Generate comparison methods other than __eq__ :key unsafe_hash: Force generation of __hash__ :key hide_internals: Hide internal methods in __repr__ + :key kw_only: Make all parameters to the generated __init__ keyword-only :return: The newly created data class """ assert issubclass(meta, DataClassMeta) diff --git a/tests.py b/tests.py index 2c17580..10e4667 100644 --- a/tests.py +++ b/tests.py @@ -245,6 +245,32 @@ class Frozen: with self.assertRaises(AttributeError): del f.b + def test_kw_only(self): + """Test effect of the kw_only decorator option.""" + @dataclass(kw_only=True) + class KwOnly: + a: int + b: str + + with self.assertRaises(TypeError): + KwOnly(1, '2') + + with self.assertRaises(TypeError): + KwOnly() + + KwOnly(a=1, b='2') + + # post-init args also become keyword only + + class KwOnlyWithPostInit(KwOnly): + def __post_init__(self, c: float): + pass + + KwOnlyWithPostInit(a=1, b='2', c=3.0) + + with self.assertRaises(TypeError): + KwOnlyWithPostInit(3.0, a=1, b='2') + def test_empty_dataclass(self): """Test data classes with no fields and data classes with only class fields.""" @dataclass