|
41 | 41 | require,
|
42 | 42 | )
|
43 | 43 | from azul.json import (
|
| 44 | + PolymorphicSerializable, |
44 | 45 | Serializable,
|
45 | 46 | )
|
46 | 47 | from azul.types import (
|
@@ -483,6 +484,10 @@ def _metadata[V](self, key: str, default: V) -> V:
|
483 | 484 | except KeyError:
|
484 | 485 | return default
|
485 | 486 |
|
| 487 | + @cached_property |
| 488 | + def discriminator(self) -> str | None: |
| 489 | + return self._metadata('discriminator', None) |
| 490 | + |
486 | 491 | def handle(self, x: str) -> T:
|
487 | 492 | if self.custom is None:
|
488 | 493 | return self._handle(x, self._reify(self.field.type))
|
@@ -536,7 +541,12 @@ def _handle(self, x: str, field_type: Any):
|
536 | 541 | elif issubclass(field_type, Serializable):
|
537 | 542 | inner_cls_name = field_type.__name__
|
538 | 543 | self.globals[inner_cls_name] = field_type
|
539 |
| - return self._serializable(x, inner_cls_name) |
| 544 | + is_polymorphic = issubclass(field_type, PolymorphicSerializable) |
| 545 | + has_discriminator = self.discriminator is not None |
| 546 | + if is_polymorphic and has_discriminator: |
| 547 | + return self._polymorphic(x, inner_cls_name) |
| 548 | + else: |
| 549 | + return self._serializable(x, inner_cls_name) |
540 | 550 | else:
|
541 | 551 | origin = get_origin(field_type)
|
542 | 552 | if origin in (Union, UnionType):
|
@@ -575,6 +585,10 @@ def _optional(self, x: str, field_type: type) -> T:
|
575 | 585 | def _serializable(self, x: str, inner_cls_name: str) -> T:
|
576 | 586 | raise NotImplementedError
|
577 | 587 |
|
| 588 | + @abstractmethod |
| 589 | + def _polymorphic(self, x: str, inner_cls_name: str) -> T: |
| 590 | + raise NotImplementedError |
| 591 | + |
578 | 592 | @abstractmethod
|
579 | 593 | def _list(self, x: str, item_type: type) -> T:
|
580 | 594 | raise NotImplementedError
|
@@ -603,6 +617,15 @@ def _serializable(self, x: str, inner_cls_name: str) -> Source:
|
603 | 617 | f'{x} = {inner_cls_name}.from_json({x})'
|
604 | 618 | ]
|
605 | 619 |
|
| 620 | + def _polymorphic(self, x: str, inner_cls_name: str) -> Source: |
| 621 | + depth = next(self.depth) |
| 622 | + cls = f'cls{depth}' |
| 623 | + return [ |
| 624 | + f'{cls} = {x}["{self.discriminator}"]', |
| 625 | + f'{cls} = {inner_cls_name}.cls_from_json({cls})', |
| 626 | + f'{x} = {cls}.from_json({x})' |
| 627 | + ] |
| 628 | + |
606 | 629 | def _primitive(self, x: str, field_type: type) -> Source:
|
607 | 630 | return [
|
608 | 631 | f'if not isinstance({x}, {field_type.__name__}):', [
|
@@ -670,6 +693,9 @@ def _optional(self, x: str, field_type: type) -> str:
|
670 | 693 | def _serializable(self, x: str, inner_cls_name: str) -> str:
|
671 | 694 | return f'{x}.to_json()'
|
672 | 695 |
|
| 696 | + def _polymorphic(self, x: str, inner_cls_name: str) -> str: |
| 697 | + return f'dict({x}.to_json(), {self.discriminator}={x}.cls_to_json())' |
| 698 | + |
673 | 699 | def _list(self, x: str, item_type: type) -> str:
|
674 | 700 | depth = next(self.depth)
|
675 | 701 | v = f'v{depth}'
|
@@ -740,3 +766,95 @@ def _set_field_metadata[T: attrs.Attribute](field: T | None, key, value):
|
740 | 766 | metadata = field.metadata.setdefault('azul', {})
|
741 | 767 | metadata[key] = value
|
742 | 768 | return field
|
| 769 | + |
| 770 | + |
| 771 | +def polymorphic[T: attrs.Attribute](field: T | None = None, |
| 772 | + *, |
| 773 | + discriminator: str |
| 774 | + ) -> T: |
| 775 | + """ |
| 776 | + Mark an attrs field to use the given name for the discriminator property in |
| 777 | + serialized instances of PolymorphicSerializable that occur in the value of |
| 778 | + that field. The given discriminator property of a serialized instance |
| 779 | + represents the type to use when deserializing that instance again. |
| 780 | +
|
| 781 | + >>> from azul.json import RegisteredPolymorphicSerializable |
| 782 | +
|
| 783 | + >>> class Inner(SerializableAttrs, RegisteredPolymorphicSerializable): |
| 784 | + ... pass |
| 785 | +
|
| 786 | + >>> @attrs.frozen |
| 787 | + ... class InnerWithInt(Inner): |
| 788 | + ... x: int |
| 789 | +
|
| 790 | + >>> @attrs.frozen |
| 791 | + ... class InnerWithStr(Inner): |
| 792 | + ... y: str |
| 793 | +
|
| 794 | + >>> @attrs.frozen(kw_only=True) |
| 795 | + ... class Outer(SerializableAttrs): |
| 796 | + ... inner: Inner = polymorphic(discriminator='type') |
| 797 | + ... inners: list[Inner] = polymorphic(discriminator='_cls') |
| 798 | +
|
| 799 | + >>> from azul.doctests import assert_json |
| 800 | +
|
| 801 | + >>> outer = Outer(inner=InnerWithInt(42), |
| 802 | + ... inners=[InnerWithStr('foo'), InnerWithInt(7)]) |
| 803 | + >>> assert_json(outer.to_json()) |
| 804 | + { |
| 805 | + "inner": { |
| 806 | + "x": 42, |
| 807 | + "type": "InnerWithInt" |
| 808 | + }, |
| 809 | + "inners": [ |
| 810 | + { |
| 811 | + "y": "foo", |
| 812 | + "_cls": "InnerWithStr" |
| 813 | + }, |
| 814 | + { |
| 815 | + "x": 7, |
| 816 | + "_cls": "InnerWithInt" |
| 817 | + } |
| 818 | + ] |
| 819 | + } |
| 820 | + >>> Outer.from_json(outer.to_json()) == outer |
| 821 | + True |
| 822 | +
|
| 823 | + In order to enable polymorphic serialization of the value of a given field, |
| 824 | + the discriminator property needs to be specified explicitly, otherwise the |
| 825 | + serialization framework will resort to the static type of the field. |
| 826 | +
|
| 827 | + >>> @attrs.frozen |
| 828 | + ... class GenericOuter[T: Inner](SerializableAttrs): |
| 829 | + ... inner: T |
| 830 | +
|
| 831 | + >>> class StaticOuter(GenericOuter[InnerWithInt]): |
| 832 | + ... pass |
| 833 | +
|
| 834 | + >>> outer = StaticOuter(InnerWithInt(42)) |
| 835 | + >>> outer.to_json() |
| 836 | + {'inner': {'x': 42}} |
| 837 | +
|
| 838 | + Despite the fact that ``{'x': 42}`` does not encode any type information, |
| 839 | + ``from_json`` can tell from the static type of the field that {'x': 42} |
| 840 | + should be deserialized as an ``InnerWithInt``. |
| 841 | +
|
| 842 | + >>> StaticOuter.from_json(outer.to_json()).inner |
| 843 | + InnerWithInt(x=42) |
| 844 | +
|
| 845 | + >>> StaticOuter.from_json(outer.to_json()) == outer |
| 846 | + True |
| 847 | +
|
| 848 | + However, when the static type of the field is not concrete, deserialization |
| 849 | + may fail or, like in this case, lose information by creating an instance of |
| 850 | + the parent class instead of the class that was serialized. |
| 851 | +
|
| 852 | + >>> @attrs.frozen |
| 853 | + ... class AbstractOuter(SerializableAttrs): |
| 854 | + ... inner: Inner |
| 855 | +
|
| 856 | + >>> outer = AbstractOuter(InnerWithInt(42)) |
| 857 | + >>> AbstractOuter.from_json(outer.to_json()).inner # doctest: +ELLIPSIS |
| 858 | + <azul.attrs.Inner object at ...> |
| 859 | + """ |
| 860 | + return _set_field_metadata(field, 'discriminator', discriminator) |
0 commit comments