From fb21cf8d63a5ad35d6540bbeb4824ffca026b5d7 Mon Sep 17 00:00:00 2001 From: William Barnhart Date: Thu, 23 Jul 2026 04:28:14 +0000 Subject: [PATCH] Fix heterogeneous union coercion raising NotImplementedError A Record field annotated with a heterogeneous union -- one whose members are neither all Model subclasses nor all scalar "literal" types, e.g. `str | list | dict | None` -- reached `UnionNode.build`, which unconditionally raised `NotImplementedError` at class-definition time. This surfaced after mode-streaming began recognizing PEP 604 (`X | Y`) unions (see faust-streaming/mode#80): such annotations now register as unions and reach this node, whereas previously an unrecognized `X | Y` fell through to a bare pass-through. The exception is raised here by faust's type-expression compiler, not by mode. There is no unambiguous way to coerce a value into a heterogeneous union, so build a pass-through expression instead -- mirroring how an all-literal union such as `Union[str, int, float]` is already handled (it collapses to a bare pass-through via `_maybe_unroll_union`). This restores the behavior that existed before the union was recognized and lets these models be defined again; `Optional`/`None` members keep their existing optional wrapping. Add regression tests covering both the `typing.Union` and PEP 604 spellings, at the `TypeExpression` level and end-to-end through `Record`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BnTZQEXqpedyrFAgwRnbsA --- faust/models/typing.py | 14 +++++++- tests/functional/models/test_typing.py | 48 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/faust/models/typing.py b/faust/models/typing.py index 030d5ecb1..70f27990c 100644 --- a/faust/models/typing.py +++ b/faust/models/typing.py @@ -331,7 +331,19 @@ def _filter_NoneType(self, union_args: Tuple) -> Iterator: return (x for x in union_args if not _is_NoneType(x)) def build(self, var: Variable, *args: Type) -> str: - raise NotImplementedError(f"Union of types {args!r} not supported") + # A union that reaches this point is heterogeneous: its members are + # neither all Model subclasses nor all built-in scalar/"literal" types + # (both of those are collapsed to a single node by + # `_maybe_unroll_union` above). There is no unambiguous way to coerce a + # value into such a union -- e.g. ``str | list | dict | None`` -- so we + # pass the already-deserialized value through unchanged. This mirrors + # the handling of an all-literal union such as ``Union[str, int, + # float]`` (which compiles to a bare pass-through) and restores the + # behavior that existed before mode-streaming started recognizing PEP + # 604 (``X | Y``) unions; that change made annotations like ``str | + # list | dict | None`` reach this node and raise NotImplementedError at + # class-definition time (faust-streaming/mode#80). + return f"{var}" class LiteralNode(Node): diff --git a/tests/functional/models/test_typing.py b/tests/functional/models/test_typing.py index ea3bdef08..65b53a864 100644 --- a/tests/functional/models/test_typing.py +++ b/tests/functional/models/test_typing.py @@ -397,6 +397,26 @@ def Xi(i): expected_types={NodeType.MODEL: {ModelT}}, ) +# Regression for faust-streaming/mode#80: a heterogeneous union whose members +# are neither all Models nor all scalar "literal" types (here list/dict are +# neither) must pass the value through unchanged instead of raising +# NotImplementedError. +CASE_UNION_STR_LIST_DICT = TypeExpressionTest( + type_expression=Union[str, list, dict], + serialized_data={"key": "value"}, + expected_result={"key": "value"}, + expected_comprehension="a", + expected_types={}, +) + +CASE_UNION_STR_LIST_DICT_NONE = TypeExpressionTest( + type_expression=Union[str, list, dict, None], + serialized_data=[1, 2, 3], + expected_result=[1, 2, 3], + expected_comprehension="(a if a is not None else None)", + expected_types={}, +) + CASES = [ CASE_TUPLE_LIST_SET_X, CASE_LIST_LIST_X, @@ -423,6 +443,8 @@ def Xi(i): CASE_LIST_NO_ARGS, CASE_UNION_STR_INT_FLOAT, CASE_UNION_X_Y, + CASE_UNION_STR_LIST_DICT, + CASE_UNION_STR_LIST_DICT_NONE, ] @@ -439,3 +461,29 @@ def test_compile(case): fun = expr.as_function(globals=globals()) assert fun(case.serialized_data) == case.expected_result assert expr.found_types == case.expected_types + + +def test_record_heterogeneous_union_field(): + # Regression test for faust-streaming/mode#80: defining a Record with a + # heterogeneous union field must not raise NotImplementedError at + # class-definition time, and the value round-trips through unchanged + # (including None, via the optional pass-through). + class SensitiveInfo(Record, namespace="test.SensitiveInfo"): + data: Union[str, list, dict, None] + meta: dict + + v = SensitiveInfo.from_data({"data": [1, 2, 3], "meta": {"k": "v"}}) + assert v.data == [1, 2, 3] + assert v.meta == {"k": "v"} + assert SensitiveInfo.from_data({"data": None, "meta": {}}).data is None + + +def test_record_heterogeneous_union_field_pep604(): + # The exact PEP 604 (X | Y) spelling from mode#80 must behave identically + # to the typing.Union spelling above. + class SensitiveInfo(Record, namespace="test.SensitiveInfoPEP604"): + data: str | list | dict | None + meta: dict + + v = SensitiveInfo.from_data({"data": {"k": "v"}, "meta": {}}) + assert v.data == {"k": "v"}