kaxil commented on code in PR #74695:
URL: https://github.com/apache/airflow/pull/74695#discussion_r4394095191


##########
providers/common/ai/tests/unit/common/ai/utils/test_output_type.py:
##########
@@ -140,11 +217,215 @@ class UnresolvedForwardReference:
     value: Missing  # type: ignore[name-defined]  # noqa: F821
 
 
+class ItemPair(NamedTuple):
+    item: Item
+
+
 class Point(NamedTuple):
     x: int
     y: str
 
 
+class TestIsModelDumpRequired:
+    @pytest.mark.parametrize(
+        ("output", "output_type", "expected"),
+        [
+            (A(x=1), [A, int], True),
+            (A(x=1), (A, int), True),
+            (A(x=1), ToolOutput(A), True),
+            (A(x=1), A, False),
+            (A(x=1), A | int, False),
+            (A(x=1), list[A], False),
+            (SubA(x=1), A, True),
+            (5, [A, int], False),
+        ],
+        ids=[
+            "list-of-types",
+            "tuple-of-types",
+            "output-marker",
+            "single-model",
+            "union",
+            "generic-list",
+            "subclass-instance",
+            "not-a-model",
+        ],
+    )
+    @requires_walker
+    def test_is_true_when_the_worker_registers_no_class_for_the_output(self, 
output, output_type, expected):
+        result = is_model_dump_required(output, output_type)
+
+        assert result is expected
+
+    
@mock.patch("airflow.providers.common.ai.utils.output_type.iter_pydantic_models",
 None)
+    def test_is_false_without_the_sdk_walker(self):
+        assert is_model_dump_required(A(x=1), [A, int]) is False
+
+
+class TestIsModelDumpRequiredForContainerOutputs:
+    @pytest.mark.parametrize(
+        ("output", "output_type"),
+        [
+            ([A(x=1)], list[A]),
+            ({"k": A(x=1)}, dict[str, A]),
+            (None, A | None),
+            ("text", [list[Item], str]),
+        ],
+        ids=["registered-list", "registered-dict", "none-for-optional", 
"no-model-in-output"],
+    )
+    @requires_walker
+    def 
test_is_false_for_a_registered_shape_or_an_output_without_a_model(self, output, 
output_type):
+        assert is_model_dump_required(output, output_type) is False
+
+    @pytest.mark.parametrize(
+        ("output", "output_type"),
+        [
+            ([Item(n=1)], [list[Item], str]),
+            ({"k": Item(n=1)}, [dict[str, Item], str]),
+            ((Item(n=1),), [tuple[Item, ...], str]),
+            (ItemPair(item=Item(n=1)), [ItemPair, str]),
+        ],
+        ids=["list", "dict", "tuple", "named-tuple"],
+    )
+    @requires_walker
+    def 
test_is_true_for_models_inside_a_container_the_worker_registers_no_class_for(
+        self, output, output_type
+    ):
+        assert is_model_dump_required(output, output_type) is True
+
+
+class TestDumpOutputForXcom:
+    @requires_walker
+    def 
test_dumped_model_with_http_url_enum_path_serializes_through_serde(self):
+        page = Page(url="https://example.com/a";, kind=Color.RED, 
path=Path("/tmp/a"))
+
+        dumped = dump_output_for_xcom(page, [Page, str], 
serialize_output=False)
+
+        assert deserialize(serialize(dumped)) == {
+            "url": "https://example.com/a";,
+            "kind": "red",
+            "path": "/tmp/a",
+        }
+
+    @requires_walker
+    def 
test_dumps_models_inside_a_list_when_output_type_is_a_list_of_types(self):
+        dumped = dump_output_for_xcom([Item(n=1)], [list[Item], str], 
serialize_output=False)
+
+        assert dumped == [{"n": 1}]
+        assert deserialize(serialize(dumped)) == [{"n": 1}]
+
+    @requires_walker
+    def test_keeps_a_registered_container_as_model_instances(self):
+        output = {"k": A(x=1)}
+
+        dumped = dump_output_for_xcom(output, dict[str, A], 
serialize_output=False)
+
+        assert dumped is output
+        assert isinstance(dumped["k"], A)
+
+    @pytest.mark.parametrize(
+        "output",
+        [datetime(2026, 1, 1, tzinfo=UTC), 5, "s", None, {"a": 1}],
+        ids=["datetime", "int", "str", "none", "dict"],
+    )
+    def test_non_model_outputs_are_returned_unchanged(self, output):
+        dumped = dump_output_for_xcom(output, [A, int], serialize_output=True)
+
+        assert dumped is output
+
+    @requires_walker
+    def 
test_dumps_the_whole_output_when_only_some_of_its_models_are_unregistered(self):
+        output = [A(x=1), Item(n=1)]
+
+        assert is_model_dump_required(output, A) is True
+        assert dump_output_for_xcom(output, A, serialize_output=False) == 
[{"x": 1}, {"n": 1}]
+
+    @pytest.mark.parametrize("model_cls", [SerializationAlias, 
SerializedByAlias])
+    @requires_walker
+    def test_dumps_field_names_not_aliases_like_dump_output_to_json(self, 
model_cls):
+        model = model_cls(user_name="kax")
+
+        dumped = dump_output_for_xcom(model, [model_cls, str], 
serialize_output=False)
+
+        assert dumped == {"user_name": "kax"}
+        assert dumped == json.loads(dump_output_to_json(model))
+
+    
@mock.patch("airflow.providers.common.ai.utils.output_type.iter_pydantic_models",
 None)
+    def test_keeps_a_container_without_the_sdk_walker(self):
+        output = [A(x=1)]
+
+        assert dump_output_for_xcom(output, list[A], serialize_output=True) is 
output
+        assert dump_output_for_xcom(output, list[A], serialize_output=False) 
is output
+
+    def test_serialize_output_keeps_python_field_types(self):
+        dumped = dump_output_for_xcom(STAMPED, Stamped, serialize_output=True)
+
+        assert type(dumped) is dict
+        assert dumped == {"d": STAMPED.d, "at": STAMPED.at, "amount": 
STAMPED.amount, "ident": STAMPED.ident}
+        assert [type(value) for value in dumped.values()] == [date, datetime, 
Decimal, UUID]
+
+    @pytest.mark.parametrize(
+        ("output", "expected"),
+        [
+            ([STAMPED], [STAMPED_JSON]),
+            ({"k": STAMPED}, {"k": STAMPED_JSON}),
+            ((STAMPED,), [STAMPED_JSON]),
+            ({STAMPED}, [STAMPED_JSON]),
+        ],
+        ids=["list", "dict", "tuple", "set"],
+    )
+    @requires_walker
+    def 
test_serialize_output_dumps_an_unregistered_container_to_json_values(self, 
output, expected):
+        dumped = dump_output_for_xcom(output, [Stamped, str], 
serialize_output=True)
+
+        assert dumped == expected
+
+    @requires_walker
+    def test_dumps_a_model_used_as_a_dict_key(self):
+        output = {Key(n=1): 1, "k": 2}
+
+        dumped = dump_output_for_xcom(output, [Key, str], 
serialize_output=True)
+
+        assert dumped == {"n=1": 1, "k": 2}
+        assert json.dumps(dumped)
+
+    
@mock.patch("airflow.providers.common.ai.utils.output_type.iter_pydantic_models",
 None)
+    def 
test_serialize_output_dumps_a_top_level_model_without_the_sdk_walker(self):
+        dumped = dump_output_for_xcom(STAMPED, Stamped, serialize_output=True)
+
+        assert type(dumped) is dict
+        assert type(dumped["d"]) is date
+
+    @pytest.mark.parametrize(
+        ("output", "output_type", "expected"),
+        [
+            ([LINK], list[Link], [LINK_SERIALIZED]),
+            ({"k": LINK}, dict[str, Link], {"k": LINK_SERIALIZED}),
+        ],
+        ids=["list", "dict"],
+    )
+    @requires_walker
+    def 
test_serialize_output_keeps_registered_models_in_a_container_that_serde_pushes(
+        self, output, output_type, expected
+    ):
+        from airflow.sdk.serde import serialize

Review Comment:
   Small one: `serialize` is already imported in the `try` block at the top of 
the file, so this import can go.



##########
providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py:
##########
@@ -38,6 +46,59 @@
 _CONVERSION_ERRORS = (ValidationError, ValueError, TypeError, AttributeError, 
KeyError, IndexError)
 
 
+def is_model_dump_required(output: Any, output_type: Any) -> bool:
+    """
+    Tell whether an output holding ``BaseModel`` instances must be pushed to 
XCom dumped.
+
+    The instances are looked up in the output itself, as 
``_iter_models_in_output`` walks it: the elements
+    of a list, tuple (a NamedTuple included), set or frozenset, and the keys 
and values of a dict. A downstream task can load a model from XCom only if the 
worker
+    registered the model's class, and the worker registers the classes 
``iter_pydantic_models`` finds in
+    ``output_type``. It finds none in a list of output types, an output 
marker, an output function, a
+    ``TypeAliasType`` or a ``NewType``, so an instance of a class it did not 
find is dumped. Without
+    ``iter_pydantic_models`` there is no evidence that a class is 
unregistered, so nothing is dumped.
+    """
+    if iter_pydantic_models is None:

Review Comment:
   After the rebase `AgentOperator` goes through main's 
`_dump_unregistered_models`, so the two operators now push different values for 
the same `output_type`. With `[M, int]`:
   
   - a model with `serialize_by_alias=True` arrives as `fullName` from the 
agent but `full_name` from `LLMOperator`
   - a `date` next to the model in a tuple stays a `date` from the agent, but 
turns into a string here
   - before 3.3, the early return here pushes `list[M]` as instances while the 
agent dumps it (and `structured_output.rst` says the worker registers no class 
there)
   
   `stability.rst` would lock in both. Could `LLMOperator` and 
`_convert_output_for_xcom` share one helper in this module? If not, the docs 
need to say where they differ.



##########
providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py:
##########
@@ -107,6 +107,86 @@ def test_tool_definitions_have_descriptions(self):
             assert tool.tool_def.description
 
 
+class TestDataFusionToolsetToolPrefix:
+    def test_prefix_is_joined_to_every_tool_name_with_an_underscore(self):
+        ts = DataFusionToolset([_make_mock_datasource_config()], 
tool_prefix="lake")
+        tools = asyncio.run(ts.get_tools(ctx=MagicMock(spec=RunContext)))
+        assert set(tools.keys()) == {"lake_list_tables", "lake_get_schema", 
"lake_query"}
+        assert all(name == tool.tool_def.name for name, tool in tools.items())
+
+    def test_prefix_is_part_of_the_toolset_id(self):
+        ts = DataFusionToolset([_make_mock_datasource_config("orders")], 
tool_prefix="lake")
+        assert ts.id == "sql_datafusion_orders_lake"
+
+    @pytest.mark.parametrize("tool_prefix", ["my-lake", "1abc", "has space"])
+    def test_prefix_must_be_identifier(self, tool_prefix):
+        with pytest.raises(ValueError, match="tool_prefix must be a valid 
Python identifier"):
+            DataFusionToolset([_make_mock_datasource_config()], 
tool_prefix=tool_prefix)
+
+    def test_prefixed_tool_dispatches_to_the_unprefixed_implementation(self):
+        ts = DataFusionToolset([_make_mock_datasource_config()], 
tool_prefix="lake")
+        ts._engine = _make_mock_engine(registered_tables={"sales": 
"s3://bucket/sales/"})
+
+        result = asyncio.run(
+            ts.call_tool(
+                "lake_list_tables", {}, ctx=MagicMock(spec=RunContext), 
tool=MagicMock(spec=ToolsetTool)
+            )
+        )
+
+        assert json.loads(result) == ["sales"]
+
+
+class TestDataFusionToolsetNameConflictHint:
+    def test_hint_points_to_tool_prefix(self):
+        ts = DataFusionToolset([_make_mock_datasource_config()])
+        assert "tool_prefix" in ts.tool_name_conflict_hint
+
+
+class TestDataFusionToolsetToolPrefixModelFacingText:
+    def test_get_schema_description_names_the_prefixed_tool(self):
+        ts = DataFusionToolset([_make_mock_datasource_config()], 
tool_prefix="lake")
+        tools = asyncio.run(ts.get_tools(ctx=MagicMock()))

Review Comment:
   Small one: `MagicMock(spec=RunContext)` here, like the other new tests in 
this file?



##########
providers/common/ai/docs/structured_output.rst:
##########
@@ -34,6 +35,8 @@ classes nested inside a function or ``@dag``-decorated body, 
parameterized
 generics, and dynamically-built classes whose ``__name__`` does not match the
 attribute they are bound to cannot be re-imported, so they are skipped with a
 warning at worker startup and the value fails to deserialize at the consumer.
+A list of types (``output_type=[MyModel, int]``) registers no class, so the 
operator pushes the
+model as a ``dict`` even when ``serialize_output`` is ``False``.

Review Comment:
   For `LLMOperator`, `@task.llm` and `LLMFileAnalysisOperator` on 3.3+ this is 
new: 1.0.0 pushed the instance there (`llm.py:390` at the tag dumps only with 
`serialize_output`), and the same holds for `ToolOutput(...)`, a `NewType` and 
a `TypeAliasType`. The upgrade bullets further down and the changelog note only 
name `AgentOperator`, and the pre-3.3 bullet doesn't hold for the LLM 
operators, which still push a `list[MyModel]` as instances there. Could the 
notes name the LLM operators too?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to