This is an automated email from the ASF dual-hosted git repository.

Lee-W pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 42dfb20b812 Assert on structured log events in common.ai tests (#74443)
42dfb20b812 is described below

commit 42dfb20b81288b4aac802c4040ac307bd4877575
Author: Wei Lee <[email protected]>
AuthorDate: Fri Oct 9 14:49:42 2026 +0900

    Assert on structured log events in common.ai tests (#74443)
---
 .../unit/common/ai/managed_agents/test_failover.py |  7 +++--
 .../tests/unit/common/ai/operators/test_agent.py   | 31 +++++++++++++++-------
 .../ai/tests/unit/common/ai/operators/test_llm.py  |  2 +-
 .../ai/tests/unit/common/ai/policies/test_retry.py | 18 +++++++++----
 .../ai/tests/unit/common/ai/sandbox/test_base.py   |  2 +-
 .../ai/tests/unit/common/ai/sandbox/test_modal.py  | 12 ++++++---
 .../unit/common/ai/toolsets/test_managed_agent.py  |  9 ++++---
 .../tests/unit/common/ai/toolsets/test_sandbox.py  | 21 ++++++++++-----
 .../unit/common/ai/utils/test_toolset_base.py      |  4 +--
 9 files changed, 71 insertions(+), 35 deletions(-)

diff --git 
a/providers/common/ai/tests/unit/common/ai/managed_agents/test_failover.py 
b/providers/common/ai/tests/unit/common/ai/managed_agents/test_failover.py
index 8a7ba89a650..e9a652ba39f 100644
--- a/providers/common/ai/tests/unit/common/ai/managed_agents/test_failover.py
+++ b/providers/common/ai/tests/unit/common/ai/managed_agents/test_failover.py
@@ -164,8 +164,11 @@ class TestInvoke:
         group = FailoverManagedAgentClient([FakeClient("a", 
raises=RuntimeError("503")), FakeClient("b")])
         with caplog.at_level("INFO"):
             group.invoke(REQUEST)
-        assert "a on fake.cloud (member 0) failed; failing over to b on 
fake.cloud (member 1)" in caplog.text
-        assert "served by standby b on fake.cloud (member 1)" in caplog.text
+        assert (
+            "Managed agent a on fake.cloud (member 0) failed; failing over to 
b on fake.cloud (member 1)"
+            in caplog.messages
+        )
+        assert "Managed agent request served by standby b on fake.cloud 
(member 1)" in caplog.messages
 
 
 class TestMetrics:
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
index bbfcf8ad514..d878ed546b2 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
@@ -486,7 +486,7 @@ class TestAgentOperatorToolsetTemplating:
         with caplog.at_level("INFO"):
             op.render_template_fields(self.CONTEXT)
 
-        assert "Rendered toolset sql-tenant_acme" in caplog.text
+        assert "Rendered toolset sql-tenant_acme" in caplog.messages
 
     def test_rendering_twice_logs_once(self, caplog):
         """@task.agent renders a second time; by then the id no longer 
changes."""
@@ -501,7 +501,7 @@ class TestAgentOperatorToolsetTemplating:
             op.render_template_fields(self.CONTEXT)
             op.render_template_fields(self.CONTEXT)
 
-        assert caplog.text.count("Rendered toolset sql-tenant_acme") == 1
+        assert caplog.messages.count("Rendered toolset sql-tenant_acme") == 1
 
     def test_toolset_without_template_fields_is_left_as_is(self):
         toolset = FunctionToolset()
@@ -1665,13 +1665,17 @@ class TestAgentOperatorDurable:
             op._log_durable_summary(counter)
 
         assert (
-            "replayed 0 cached steps (0 model, 0 tool), cached 3 new steps (2 
model, 1 tool)" in caplog.text
+            "Durable: replayed 0 cached steps (0 model, 0 tool), cached 3 new 
steps (2 model, 1 tool)"
+            in caplog.messages
         )
         assert (
-            "3 tool results were not cached, and a retry runs them again: 
run_query (x2), get_schema"
-            in caplog.text
+            "Durable: 3 tool results were not cached, and a retry runs them 
again: run_query (x2), get_schema"
+            in caplog.messages
         )
-        assert "1 model responses were not cached, and a retry re-runs them" 
in caplog.text
+        assert (
+            "Durable: 1 model responses were not cached, and a retry re-runs 
them and every step after "
+            "the first of them"
+        ) in caplog.messages
 
     def test_durable_summary_has_no_warning_when_everything_was_cached(self, 
caplog):
         counter = DurableStepCounter()
@@ -1681,7 +1685,10 @@ class TestAgentOperatorDurable:
         with caplog.at_level("INFO"):
             op._log_durable_summary(counter)
 
-        assert "cached 1 new steps (1 model, 0 tool)" in caplog.text
+        assert (
+            "Durable: replayed 0 cached steps (0 model, 0 tool), cached 1 new 
steps (1 model, 0 tool)"
+            in caplog.messages
+        )
         assert not [r for r in caplog.records if r.levelname == "WARNING"]
 
     
@patch("airflow.providers.common.ai.operators.agent.AgentOperator._build_durable_storage")
@@ -1716,8 +1723,14 @@ class TestAgentOperatorDurable:
         with caplog.at_level("INFO"), pytest.raises(RuntimeError, 
match="downstream failure"):
             op.execute(context=_make_context())
 
-        assert "cached 2 new steps (2 model, 0 tool)" in caplog.text
-        assert "1 tool results were not cached, and a retry runs them again: 
send_email" in caplog.text
+        assert (
+            "Durable: replayed 0 cached steps (0 model, 0 tool), cached 2 new 
steps (2 model, 0 tool)"
+            in caplog.messages
+        )
+        assert (
+            "Durable: 1 tool results were not cached, and a retry runs them 
again: send_email"
+            in caplog.messages
+        )
 
     @patch("pydantic_ai.models.wrapper.infer_model", side_effect=lambda m: m)
     @patch("pydantic_ai.models.infer_model", autospec=True)
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
index 51d79fb8baa..123b8b4cdd3 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
@@ -404,7 +404,7 @@ class TestLLMOperatorConfidenceGate:
             output = op.execute(context)
 
         assert Summary.model_validate(output).text == "t"
-        assert "'decision' was not pushed to XCom" in caplog.text
+        assert "No task instance in the context; 'decision' was not pushed to 
XCom." in caplog.messages
 
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_resolved_model_name_pushed_to_xcom(self, mock_hook_cls, 
make_mock_run_result):
diff --git a/providers/common/ai/tests/unit/common/ai/policies/test_retry.py 
b/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
index bd34472b8de..303114e2c24 100644
--- a/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
+++ b/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
@@ -20,6 +20,7 @@ import copy
 import json
 import logging
 import math
+import re
 import traceback
 import warnings
 from datetime import timedelta
@@ -990,8 +991,8 @@ class TestFallbackBehaviour:
 
         assert decision.action == RetryAction.DEFAULT
         assert decision.reason == "classifier answer not applied 
(model_error); task retry settings apply"
-        assert "answered 'not_a_category', which is not a configured category" 
in caplog.text
-        assert "KeyError" not in caplog.text
+        assert "Classifier answered 'not_a_category', which is not a 
configured category" in caplog.messages
+        assert not [r for r in caplog.records if r.exc_info and r.exc_info[0] 
is KeyError]
 
 
 class TestOnUncertain:
@@ -1162,7 +1163,11 @@ class TestOnUncertain:
         assert decision.action == RetryAction.RETRY
         assert decision.retry_delay == timedelta(seconds=5)
         assert decision.reason == "classifier answer not applied 
(model_error); rule"
-        assert "decided nothing" in caplog.text
+        assert any(
+            r.name == "airflow.providers.common.ai.policies.retry"
+            and re.match(r"MagicMock decided nothing", r.getMessage())
+            for r in caplog.records
+        )
 
     def test_fallback_policy_raising_falls_to_the_outer_rules(self, caplog):
         """A third-party policy that blows up must not take the classifier's 
rules floor with it."""
@@ -1180,7 +1185,7 @@ class TestOnUncertain:
 
         assert decision.action == RetryAction.RETRY
         assert decision.reason == "classifier answer not applied 
(model_error); rule"
-        assert "fallback_policy failed" in caplog.text
+        assert "fallback_policy failed, using fallback rules" in 
caplog.messages
 
     @patch(HOOK, autospec=True)
     def 
test_nested_classifiers_both_down_still_reach_the_outer_fail_rule(self, 
mock_hook_cls):
@@ -1343,7 +1348,10 @@ class TestLLMRetryPolicy:
             decision = policy.evaluate(ValueError("x"), try_number=1, 
max_tries=3)
 
         assert decision.action == RetryAction.DEFAULT
-        assert "use ClassifierRetryPolicy" in caplog.text
+        assert (
+            "This model cannot answer ErrorClassification. If it is a decision 
model, "
+            "use ClassifierRetryPolicy, which asks it a typed question."
+        ) in caplog.messages
 
     def test_no_warning_for_any_instructions(self):
         with warnings.catch_warnings():
diff --git a/providers/common/ai/tests/unit/common/ai/sandbox/test_base.py 
b/providers/common/ai/tests/unit/common/ai/sandbox/test_base.py
index 82e43e34c2a..02bff63dd97 100644
--- a/providers/common/ai/tests/unit/common/ai/sandbox/test_base.py
+++ b/providers/common/ai/tests/unit/common/ai/sandbox/test_base.py
@@ -549,7 +549,7 @@ class TestAttachablePolicy:
         attached = backend.attach("sb", owner="o", holder="h")
 
         assert attached.network is None
-        assert "network stamp this backend cannot read" in caplog.text
+        assert "Sandbox sb carries a network stamp this backend cannot read: 
'open-ish'" in caplog.messages
 
     def test_release_clears_only_the_callers_claim(self):
         backend = TaggedBackend({"sb": {OWNER_TAG: "o", HOLDER_TAG: 
"someone_else"}})
diff --git a/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py 
b/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
index be57f1db80b..348f3865563 100644
--- a/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
+++ b/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
@@ -20,6 +20,7 @@ import builtins
 import importlib
 import json
 import logging
+import re
 import sys
 import threading
 
@@ -510,8 +511,11 @@ class TestAddressAllowlist:
             backend, fake, SandboxSpec(allow_egress_to=["pypi.org"], 
allow_egress_to_cidrs=["1.1.1.1/32"])
         )
 
-        assert "port 443 is admitted to ANY address" in caplog.text
-        assert "1.1.1.1/32" in caplog.text
+        assert any(
+            r.name == "airflow.providers.common.ai.sandbox.modal"
+            and re.match(r".*port 443 is admitted to ANY 
address.*1\.1\.1\.1/32", r.getMessage())
+            for r in caplog.records
+        )
 
     def test_an_address_list_alone_does_not_warn(self, backend, fake, caplog):
         caplog.set_level(logging.WARNING, 
logger="airflow.providers.common.ai.sandbox.modal")
@@ -1351,7 +1355,7 @@ class TestLivenessProbe:
 
         assert result.timed_out is True
         assert result.sandbox_terminated is False
-        assert "Could not check whether Modal sandbox" in caplog.text
+        assert "Could not check whether Modal sandbox sb-0 is alive" in 
caplog.messages
         # The handle is still cached, so the next command needs no lookup and 
no create.
         fake.Sandbox.from_id_error = AssertionError("a kept handle must not be 
looked up again")
         sandbox.process = FakeProcess(returncode=0)
@@ -1400,7 +1404,7 @@ class TestDestroy:
 
         backend.destroy(handle)
 
-        assert "Could not terminate" in caplog.text
+        assert "Could not terminate Modal sandbox sb-0" in caplog.messages
 
     def test_can_be_called_while_a_command_is_outstanding(self, backend, fake):
         """
diff --git 
a/providers/common/ai/tests/unit/common/ai/toolsets/test_managed_agent.py 
b/providers/common/ai/tests/unit/common/ai/toolsets/test_managed_agent.py
index 9bb03c1c27b..ef156869859 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_managed_agent.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_managed_agent.py
@@ -233,8 +233,7 @@ class TestCallTool:
     async def test_logs_the_agent_it_consulted(self, caplog):
         with caplog.at_level("INFO"):
             await call(FakeManagedAgentToolset())
-        assert "specialist-1" in caplog.text
-        assert "fake.cloud" in caplog.text
+        assert "Consulted managed agent specialist-1 on fake.cloud" in 
caplog.messages
 
     @pytest.mark.asyncio
     
@mock.patch("airflow.providers.common.ai.toolsets.managed_agent.Stats.incr", 
autospec=True)
@@ -313,8 +312,10 @@ class TestManagedAgentToolset:
         client = FakeClient(ref_raises=RuntimeError("Connection 'standby' not 
found"))
         with caplog.at_level("WARNING"):
             assert await call(ManagedAgentToolset(client, tool_name="ask"), 
"q") == "the answer: q"
-        assert "identity could not be resolved" in caplog.text
-        assert "Connection 'standby' not found" in caplog.text
+        [record] = [
+            r for r in caplog.records if r.getMessage() == "Managed agent 
identity could not be resolved"
+        ]
+        assert str(record.exc_info[1]) == "Connection 'standby' not found"
 
     @pytest.mark.asyncio
     
@mock.patch("airflow.providers.common.ai.toolsets.managed_agent.Stats.incr", 
autospec=True)
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py 
b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
index 48d9f7edc5c..4023628b0dd 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import asyncio
+import re
 import threading
 import time
 from types import SimpleNamespace
@@ -689,7 +690,7 @@ class TestLifecycle:
         async with ts:
             await _call(ts, "run_command", {"command": "x"})
 
-        assert "may need manual cleanup" in caplog.text
+        assert "Failed to destroy sandbox box-1 on backend rec; it may need 
manual cleanup" in caplog.messages
 
     @pytest.mark.asyncio
     async def test_reentry_creates_a_fresh_sandbox(self):
@@ -959,7 +960,11 @@ class TestExports:
                 async with ts:
                     await _call(ts, "run_command", {"command": "x"})
 
-        assert "Could not remove" in caplog.text
+        assert any(
+            r.name == "airflow.providers.common.ai.toolsets.sandbox"
+            and re.match(r"Could not remove .*out\.bin\.\w+\.partial after a 
failed export", r.getMessage())
+            for r in caplog.records
+        )
 
     @pytest.mark.asyncio
     async def test_a_local_destination_gets_its_directories(self, tmp_path):
@@ -1005,7 +1010,7 @@ class TestExports:
             await _call(ts, "run_command", {"command": "x"})
 
         assert (tmp_path / "out.bin").read_bytes() == b"payload"
-        assert "Failed to destroy sandbox box-1" in caplog.text
+        assert "Failed to destroy sandbox box-1 on backend rec; it may need 
manual cleanup" in caplog.messages
 
     @pytest.mark.asyncio
     async def 
test_a_run_that_never_used_its_sandbox_cannot_deliver_its_files(self, tmp_path):
@@ -1237,8 +1242,10 @@ class TestAttachMode:
         async with ts:
             backend.tags_errors = [RuntimeError("tags service down")] * 3
 
-        assert "after 3 attempts" in caplog.text
-        assert "held by me" in caplog.text
+        assert (
+            "Failed to release sandbox sb-1 on backend attachable after 3 
attempts; it stays marked as "
+            "held by me, and only that holder, or the task that created the 
sandbox, can use it"
+        ) in caplog.messages
         assert backend.tags["sb-1"][HOLDER_TAG] == "me"
 
     @pytest.mark.asyncio
@@ -1251,7 +1258,7 @@ class TestAttachMode:
             backend.tags_errors = [RuntimeError("blip"), RuntimeError("blip")]
 
         assert HOLDER_TAG not in backend.tags["sb-1"]
-        assert "Failed to release" not in caplog.text
+        assert not any("Failed to release" in m for m in caplog.messages)
 
     @pytest.mark.asyncio
     async def test_releasing_a_sandbox_that_has_ended_is_quiet(self, caplog):
@@ -1263,7 +1270,7 @@ class TestAttachMode:
         async with ts:
             del backend.tags["sb-1"]
 
-        assert "Failed to release" not in caplog.text
+        assert not any("Failed to release" in m for m in caplog.messages)
 
     @pytest.mark.asyncio
     async def 
test_the_sandbox_is_released_but_not_destroyed_when_a_call_raises(self):
diff --git 
a/providers/common/ai/tests/unit/common/ai/utils/test_toolset_base.py 
b/providers/common/ai/tests/unit/common/ai/utils/test_toolset_base.py
index 30f085a42f9..df0f364ede8 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_toolset_base.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_toolset_base.py
@@ -159,7 +159,7 @@ class TestMasking:
             _call(_ScriptedToolset(ApprovalRequired(metadata={"reason": 
registered_secret})))
 
         assert caught.value.metadata == {"reason": "***"}
-        assert "failed" not in caplog.text
+        assert not any("failed" in m for m in caplog.messages)
 
     def test_masks_the_exceptions_inside_an_exception_group(self, 
registered_secret):
         cause = ConnectionError(f"socket closed by {registered_secret}")
@@ -276,4 +276,4 @@ class TestWithMasking:
         with pytest.raises(ValueError, match="boom"):
             _call(MaskingToolset(wrapped=_ScriptedToolset(ValueError("boom"))))
 
-        assert caplog.text.count("Tool t failed") == 1
+        assert caplog.messages.count("Tool t failed") == 1

Reply via email to