Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/fable-library-py/fable_library/union.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,13 @@ def tagged_union(tag: int):
Uses @dataclass_transform() so type checkers understand:
- Field annotations become constructor parameters
- __match_args__ is generated
- __eq__, __repr__, __hash__ are generated
- __eq__, __repr__ are generated

Additionally sets:
- cls.tag = tag (numeric case discriminator)
- cls.fields property (list of field values for backwards compat)
- cls.__hash__ from HashableBase (dataclass sets it to None when
generating __eq__, which would make union values unhashable)
"""

def decorator[T](cls: type[T]) -> type[T]:
Expand All @@ -148,6 +150,12 @@ def fields(self) -> Array[Any]:
return Array[Any]([getattr(self, name) for name in field_names])

dc_cls.fields = fields

# dataclass() generates __eq__ and therefore sets __hash__ to None.
# Restore the hash protocol so union values stay hashable via
# GetHashCode, e.g. when a union is a field of another union.
dc_cls.__hash__ = HashableBase.__hash__

return dc_cls

return decorator
Expand Down
45 changes: 45 additions & 0 deletions src/fable-library-py/tests/test_union.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from fable_library.union import Union, tagged_union


class _Shape(Union):
@staticmethod
def cases() -> list[str]:
return ["Circle", "Boxed"]


@tagged_union(0)
class Shape_Circle(_Shape):
radius: int


@tagged_union(1)
class Shape_Boxed(_Shape):
inner: _Shape


def test_union_case_is_hashable() -> None:
# dataclass() generates __eq__, which sets __hash__ to None unless it is
# restored -- union values must keep supporting the hash protocol.
assert isinstance(hash(Shape_Circle(1)), int)


def test_equal_unions_have_equal_hashes() -> None:
assert Shape_Circle(2) == Shape_Circle(2)
assert hash(Shape_Circle(2)) == hash(Shape_Circle(2))


def test_nested_union_is_hashable() -> None:
# A union hashes over its fields, so a union nested as a field of another
# union must be hashable as well.
nested = Shape_Boxed(Shape_Circle(3))
assert nested.GetHashCode() == Shape_Boxed(Shape_Circle(3)).GetHashCode()
assert hash(nested) == hash(Shape_Boxed(Shape_Circle(3)))


def test_union_works_in_sets() -> None:
# set([...]) instead of a set literal: type checkers model tagged_union case
# classes as non-frozen dataclasses (statically unhashable), while the
# decorator restores __hash__ at runtime.
distinct = set([Shape_Circle(1), Shape_Circle(1), Shape_Boxed(Shape_Circle(1))])
assert len(distinct) == 2
assert Shape_Circle(1) in distinct
Loading