From b11eeed29d049d5a6a791c1013acd5e63c806bd8 Mon Sep 17 00:00:00 2001 From: William Barnhart Date: Wed, 22 Jul 2026 08:12:56 -0400 Subject: [PATCH 1/2] Add union annotation compatibility regression matrix --- .../models/test_union_compatibility.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/functional/models/test_union_compatibility.py diff --git a/tests/functional/models/test_union_compatibility.py b/tests/functional/models/test_union_compatibility.py new file mode 100644 index 000000000..16115e6a6 --- /dev/null +++ b/tests/functional/models/test_union_compatibility.py @@ -0,0 +1,87 @@ +"""Compatibility tests for model field union annotations. + +These tests exercise the boundary between mode.utils.objects union detection and +Faust's model type-expression compiler. They intentionally cover both +``typing.Union`` and PEP 604 (``X | Y``) syntax so dependency upgrades cannot +silently change which annotations reach ``UnionNode``. +""" + +from typing import Any, Dict, List, Optional, Union + +import pytest + +from faust import Record +from faust.models.typing import TypeExpression + + +class Child(Record, namespace="test.union.Child"): + value: int + + +@pytest.mark.parametrize( + "annotation,value", + [ + (str | int | None, "hello"), + (str | int | None, 42), + (str | int | None, None), + (Union[str, int, None], "hello"), + (Union[str, int, None], 42), + (Union[str, int, None], None), + (Child | None, {"value": 3}), + (Optional[Child], {"value": 3}), + (List[Optional[int]], [1, None, 2]), + (Dict[str, Optional[int]], {"one": 1, "none": None}), + ], +) +def test_supported_union_type_expressions_compile(annotation, value): + """Existing supported union forms must continue compiling.""" + converter = TypeExpression(annotation).as_function() + result = converter(value) + if annotation in {Child | None, Optional[Child]}: + assert result == Child(value=3) + else: + assert result == value + + +@pytest.mark.parametrize( + "annotation", + [ + str | list | dict | None, + Union[str, list, dict, None], + str | list[str] | dict[str, Any] | None, + Union[str, List[str], Dict[str, Any], None], + ], +) +@pytest.mark.xfail( + reason="mode#80: Faust does not yet pass through heterogeneous JSON-native unions", + strict=True, +) +def test_json_native_heterogeneous_unions_compile(annotation): + """JSON-native heterogeneous unions should be safe pass-through fields.""" + converter = TypeExpression(annotation).as_function() + for value in ("secret", ["secret"], {"secret": True}, None): + assert converter(value) == value + + +def test_issue_80_record_declaration_regression(): + """The exact mode#80 declaration must remain import/class-definition safe.""" + + class SensitiveInfo(Record, namespace="test.union.SensitiveInfo"): + data: str | list | dict | None + meta: dict + + value = SensitiveInfo(data={"token": "redacted"}, meta={"source": "test"}) + assert value.data == {"token": "redacted"} + + +@pytest.mark.parametrize( + "annotation", + [ + datetime_union := (str | Child), + Union[str, Child], + ], +) +def test_ambiguous_model_and_scalar_union_remains_explicitly_unsupported(annotation): + """Do not accidentally coerce ambiguous model/scalar unions as pass-through.""" + with pytest.raises(NotImplementedError, match="Union of types"): + TypeExpression(annotation).as_function() From 0dc853e53ef8a33ab26bf62b5822729f02a64741 Mon Sep 17 00:00:00 2001 From: William Barnhart Date: Wed, 22 Jul 2026 08:18:15 -0400 Subject: [PATCH 2/2] Refine union compatibility regression matrix --- .../models/test_union_compatibility.py | 77 +++++++++++-------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/tests/functional/models/test_union_compatibility.py b/tests/functional/models/test_union_compatibility.py index 16115e6a6..a0a038322 100644 --- a/tests/functional/models/test_union_compatibility.py +++ b/tests/functional/models/test_union_compatibility.py @@ -1,11 +1,13 @@ """Compatibility tests for model field union annotations. -These tests exercise the boundary between mode.utils.objects union detection and -Faust's model type-expression compiler. They intentionally cover both +These tests exercise the boundary between ``mode.utils.objects`` union +detection and Faust's model type-expression compiler. They cover both ``typing.Union`` and PEP 604 (``X | Y``) syntax so dependency upgrades cannot silently change which annotations reach ``UnionNode``. """ +from datetime import datetime +from decimal import Decimal from typing import Any, Dict, List, Optional, Union import pytest @@ -19,52 +21,56 @@ class Child(Record, namespace="test.union.Child"): @pytest.mark.parametrize( - "annotation,value", + "annotation,value,expected", [ - (str | int | None, "hello"), - (str | int | None, 42), - (str | int | None, None), - (Union[str, int, None], "hello"), - (Union[str, int, None], 42), - (Union[str, int, None], None), - (Child | None, {"value": 3}), - (Optional[Child], {"value": 3}), - (List[Optional[int]], [1, None, 2]), - (Dict[str, Optional[int]], {"one": 1, "none": None}), + (str | int | None, "hello", "hello"), + (str | int | None, 42, 42), + (str | int | None, None, None), + (Union[str, int, None], "hello", "hello"), + (Union[str, int, None], 42, 42), + (Union[str, int, None], None, None), + (Child | None, {"value": 3}, Child(value=3)), + (Optional[Child], {"value": 3}, Child(value=3)), + (List[Optional[int]], [1, None, 2], [1, None, 2]), + ( + Dict[str, Optional[int]], + {"one": 1, "none": None}, + {"one": 1, "none": None}, + ), ], ) -def test_supported_union_type_expressions_compile(annotation, value): +def test_supported_union_type_expressions_compile(annotation, value, expected): """Existing supported union forms must continue compiling.""" converter = TypeExpression(annotation).as_function() - result = converter(value) - if annotation in {Child | None, Optional[Child]}: - assert result == Child(value=3) - else: - assert result == value + assert converter(value) == expected -@pytest.mark.parametrize( - "annotation", - [ - str | list | dict | None, - Union[str, list, dict, None], - str | list[str] | dict[str, Any] | None, - Union[str, List[str], Dict[str, Any], None], - ], -) +JSON_PASSTHROUGH_UNIONS = [ + str | list | dict | None, + Union[str, list, dict, None], + str | list[str] | dict[str, Any] | None, + Union[str, List[str], Dict[str, Any], None], +] + + +@pytest.mark.parametrize("annotation", JSON_PASSTHROUGH_UNIONS) @pytest.mark.xfail( - reason="mode#80: Faust does not yet pass through heterogeneous JSON-native unions", + reason="mode#80: Faust does not yet pass through heterogeneous JSON unions", strict=True, ) def test_json_native_heterogeneous_unions_compile(annotation): - """JSON-native heterogeneous unions should be safe pass-through fields.""" + """JSON-shaped heterogeneous unions should be safe pass-through fields.""" converter = TypeExpression(annotation).as_function() for value in ("secret", ["secret"], {"secret": True}, None): assert converter(value) == value +@pytest.mark.xfail( + reason="mode#80: class creation currently rejects the reported PEP 604 union", + strict=True, +) def test_issue_80_record_declaration_regression(): - """The exact mode#80 declaration must remain import/class-definition safe.""" + """Exercise the exact model declaration reported in mode issue #80.""" class SensitiveInfo(Record, namespace="test.union.SensitiveInfo"): data: str | list | dict | None @@ -77,11 +83,14 @@ class SensitiveInfo(Record, namespace="test.union.SensitiveInfo"): @pytest.mark.parametrize( "annotation", [ - datetime_union := (str | Child), + str | Child, Union[str, Child], + str | Decimal, + Union[str, datetime], + list[Child] | dict[str, Child], ], ) -def test_ambiguous_model_and_scalar_union_remains_explicitly_unsupported(annotation): - """Do not accidentally coerce ambiguous model/scalar unions as pass-through.""" +def test_ambiguous_or_coercion_sensitive_unions_remain_unsupported(annotation): + """Do not hide unions that require runtime type selection or coercion.""" with pytest.raises(NotImplementedError, match="Union of types"): TypeExpression(annotation).as_function()