Skip to content

Commit

Permalink
Meta: implement option kw_only
Browse files Browse the repository at this point in the history
  • Loading branch information
biqqles committed Jun 1, 2021
1 parent e80b6c8 commit 4813f08
Show file tree
Hide file tree
Showing 4 changed files with 38 additions and 8 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
16 changes: 8 additions & 8 deletions dataclassy/dataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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__
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions dataclassy/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 4813f08

Please sign in to comment.