diff --git a/Doc/library/dataclasses.rst b/Doc/library/dataclasses.rst index 71768abf80c47a..322b31f311c5a7 100644 --- a/Doc/library/dataclasses.rst +++ b/Doc/library/dataclasses.rst @@ -171,9 +171,14 @@ Module-level decorators, classes, and functions def __init__(self, a: int, b: int = 0): - :exc:`TypeError` will be raised if a field without a default value - follows a field with a default value. This is true either when this - occurs in a single class, or as a result of class inheritance. + When a field without a default value follows a field with a default value + (including as a result of class inheritance), the former field, and all + successive fields, will be made keyword-only in the parameters of + :meth:`__init__`. + + .. versionchanged:: 3.9 + Non-defaulted fields following defaulted field used raise an + :exc:`TypeError`, but are now converted to keyword-only. .. function:: field(*, default=MISSING, default_factory=MISSING, repr=True, hash=None, init=True, compare=True, metadata=None) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 00851c648a13b5..818de95c67b4cd 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -486,21 +486,6 @@ def _init_param(f): def _init_fn(fields, frozen, has_post_init, self_name, globals): # fields contains both real fields and InitVar pseudo-fields. - # Make sure we don't have fields without defaults following fields - # with defaults. This actually would be caught when exec-ing the - # function source code, but catching it here gives a better error - # message, and future-proofs us in case we build up the function - # using ast. - seen_default = False - for f in fields: - # Only consider fields in the __init__ call. - if f.init: - if not (f.default is MISSING and f.default_factory is MISSING): - seen_default = True - elif seen_default: - raise TypeError(f'non-default argument {f.name!r} ' - 'follows default argument') - locals = {f'_type_{f.name}': f.type for f in fields} locals.update({ 'MISSING': MISSING, @@ -525,8 +510,22 @@ def _init_fn(fields, frozen, has_post_init, self_name, globals): if not body_lines: body_lines = ['pass'] + # Build arguments: once we've encountered defaulted fields, all + # following non-defaulted fields must be passed via keyword + seen_default = False + keyword_only = False + args = [self_name] + for f in fields: + if f.init: + if not (f.default is MISSING and f.default_factory is MISSING): + seen_default = True + elif seen_default and not keyword_only: + keyword_only = True + args.append("*") + args.append(_init_param(f)) + return _create_fn('__init__', - [self_name] + [_init_param(f) for f in fields if f.init], + args, body_lines, locals=locals, globals=globals, diff --git a/Lib/test/test_dataclasses.py b/Lib/test/test_dataclasses.py index 8f9fb2ce8c169c..cb99263a95095a 100644 --- a/Lib/test/test_dataclasses.py +++ b/Lib/test/test_dataclasses.py @@ -62,40 +62,97 @@ class C: o = C(3) self.assertEqual((o.x, o.y), (3, 0)) + def test_two_fields_one_default_nondefault_follows_default(self): # Non-defaults following defaults. + @dataclass + class C: + x: int = 0 + y: int + with self.assertRaisesRegex(TypeError, - "non-default argument 'y' follows " - "default argument"): - @dataclass - class C: - x: int = 0 - y: int + "__init__\\(\\) missing 1 " + "required keyword-only argument: " + "'y'"): + o = C(3) + + with self.assertRaisesRegex(TypeError, + "__init__\\(\\) takes from 1 to " + "2 positional arguments but 3 " + "were given"): + o = C(3, 4) + + o = C(3, y=4) + self.assertEqual((o.x, o.y), (3, 4)) + + o = C(x=2, y=4) + self.assertEqual((o.x, o.y), (2, 4)) + o = C(y=5) + self.assertEqual((o.x, o.y), (0, 5)) + + def test_two_fields_one_default_derived_adds_nondefault(self): # A derived class adds a non-default field after a default one. + @dataclass + class B: + x: int = 0 + + @dataclass + class C(B): + y: int + with self.assertRaisesRegex(TypeError, - "non-default argument 'y' follows " - "default argument"): - @dataclass - class B: - x: int = 0 + "__init__\\(\\) missing 1 " + "required keyword-only argument: " + "'y'"): + o = C(3) - @dataclass - class C(B): - y: int + with self.assertRaisesRegex(TypeError, + "__init__\\(\\) takes from 1 to " + "2 positional arguments but 3 " + "were given"): + o = C(3, 4) + + o = C(3, y=4) + self.assertEqual((o.x, o.y), (3, 4)) + o = C(x=2, y=4) + self.assertEqual((o.x, o.y), (2, 4)) + + o = C(y=5) + self.assertEqual((o.x, o.y), (0, 5)) + + def test_two_fields_one_default_derived_updates_to_default(self): # Override a base class field and add a default to # a field which didn't use to have a default. + @dataclass + class B: + x: int + y: int + + @dataclass + class C(B): + x: int = 0 + with self.assertRaisesRegex(TypeError, - "non-default argument 'y' follows " - "default argument"): - @dataclass - class B: - x: int - y: int + "__init__\\(\\) missing 1 " + "required keyword-only argument: " + "'y'"): + o = C(3) - @dataclass - class C(B): - x: int = 0 + with self.assertRaisesRegex(TypeError, + "__init__\\(\\) takes from 1 to " + "2 positional arguments but 3 " + "were given"): + o = C(3, 4) + + o = C(3, y=4) + self.assertEqual((o.x, o.y), (3, 4)) + + o = C(x=2, y=4) + self.assertEqual((o.x, o.y), (2, 4)) + + o = C(y=5) + self.assertEqual((o.x, o.y), (0, 5)) def test_overwrite_hash(self): # Test that declaring this class isn't an error. It should diff --git a/Misc/ACKS b/Misc/ACKS index 253e2f6133d587..31c9fa76dfa8de 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -1217,6 +1217,7 @@ Grant Olson Koray Oner Piet van Oostrum Tomas Oppelstrup +Laurie Opperman Jason Orendorff Bastien Orivel orlnub123 diff --git a/Misc/NEWS.d/next/Library/2019-11-21-15-41-12.bpo-36077.5VAdjq.rst b/Misc/NEWS.d/next/Library/2019-11-21-15-41-12.bpo-36077.5VAdjq.rst new file mode 100644 index 00000000000000..74c62275ad816d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2019-11-21-15-41-12.bpo-36077.5VAdjq.rst @@ -0,0 +1 @@ +When a non-defaulted dataclass field follows a defaulted field, the behaviour has changed from raising a ``TypeError`` to making all arguments in ``__init__`` following the defaulted field keyword-only. \ No newline at end of file