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

shahar1 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 0bac61574a5 Restore Dag-parse-time validation for common.ai operator 
arguments (#70628)
0bac61574a5 is described below

commit 0bac61574a5b6f49ae930304298650e3c94681e9
Author: Baha Bouali <[email protected]>
AuthorDate: Sat Sep 12 09:30:46 2026 +0100

    Restore Dag-parse-time validation for common.ai operator arguments (#70628)
---
 .../src/airflow/providers/common/ai/operators/agent.py | 13 +++++++------
 .../providers/common/ai/operators/document_loader.py   | 16 +++++++++++-----
 .../ai/tests/unit/common/ai/operators/test_agent.py    | 18 +++++++-----------
 .../unit/common/ai/operators/test_document_loader.py   | 16 ++++++++++++----
 4 files changed, 37 insertions(+), 26 deletions(-)

diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
index 83a03bfda02..dac5e5b2830 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
@@ -297,6 +297,13 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
             # replay. Reject the combination rather than silently 
mis-replaying.
             raise ValueError("durable=True and code_mode=True cannot be used 
together.")
 
+        if message_history is not None and enable_hitl_review:
+            # The post-review transcript is not recoverable today 
(run_hitl_review
+            # returns only the final string), so emitting the pre-review 
transcript
+            # would silently drop the human-approved turns. Block until HITL 
can
+            # surface the final message history.
+            raise ValueError("message_history and enable_hitl_review=True 
cannot be used together.")
+
         self.enable_hitl_review = enable_hitl_review
         self.max_hitl_iterations = max_hitl_iterations
         self.hitl_timeout = hitl_timeout
@@ -421,12 +428,6 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
         )
 
     def execute(self, context: Context) -> Any:
-        # message_history is a template field; validate the combination after 
rendering.
-        if self.message_history is not None and self.enable_hitl_review:
-            # run_hitl_review returns only the final string, so the pre-review 
transcript would drop
-            # the human-approved turns. Block until HITL can surface the final 
message history.
-            raise ValueError("message_history and enable_hitl_review=True 
cannot be used together.")
-
         if self.enable_hitl_review and not isinstance(self.prompt, str):
             raise TypeError(
                 f"{type(self).__name__}: enable_hitl_review=True is not 
supported "
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py
 
b/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py
index 217917f8112..f38d9c75ab0 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py
@@ -136,6 +136,10 @@ class DocumentLoaderOperator(BaseOperator):
         **kwargs: Any,
     ) -> None:
         super().__init__(**kwargs)
+        if source_path is not None and source_bytes is not None:
+            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes', not both.")
+        if source_path is None and source_bytes is None:
+            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes'.")
         self.source_path = source_path
         self.source_conn_id = source_conn_id
         self.source_bytes = source_bytes
@@ -148,13 +152,15 @@ class DocumentLoaderOperator(BaseOperator):
         self.json_text_field = json_text_field
 
     def execute(self, context: Context) -> list[dict[str, Any]]:
-        # source_path/file_type are template fields; validate after rendering, 
not in __init__.
-        if self.source_path is not None and self.source_bytes is not None:
-            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes', not both.")
-        if self.source_path is None and self.source_bytes is None:
-            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes'.")
+        # file_type and source_path can each be *supplied* (as non-None 
argument) yet still
+        # render to None. These aren't provision checks (that already happened 
in __init__);
+        # they guard the rendered value itself, since 
_parse_bytes/_resolve_files need a real
+        # value to work with. Checking this in __init__ would validate the 
unrendered template
+        # string instead of the value actually used here.
         if self.source_bytes is not None and self.file_type is None:
             raise ValueError("'file_type' is required when using 
'source_bytes' (e.g. '.pdf').")
+        if self.source_bytes is None and self.source_path is None:
+            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes'.")
 
         if self.source_bytes is not None:
             if TYPE_CHECKING:
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 760bacb8cc7..41470724d60 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
@@ -877,20 +877,16 @@ class TestAgentOperatorMessageHistory:
         assert kwargs["usage_limits"] is limits
         assert kwargs["message_history"] == []
 
-    @pytest.mark.skipif(
-        not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible 
with Airflow >= 3.1.0"
-    )
     def test_message_history_with_hitl_review_raises(self):
         """message_history cannot be combined with HITL review (post-review 
transcript is lost)."""
-        op = AgentOperator(
-            task_id="t",
-            prompt="run",
-            llm_conn_id="c",
-            message_history=[],
-            enable_hitl_review=True,
-        )
         with pytest.raises(ValueError, match="message_history and 
enable_hitl_review"):
-            op.execute(context={})
+            AgentOperator(
+                task_id="t",
+                prompt="run",
+                llm_conn_id="c",
+                message_history=[],
+                enable_hitl_review=True,
+            )
 
     @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_document_loader.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py
index 575dbaed0a4..111cf7738e5 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py
@@ -50,16 +50,16 @@ class TestDocumentLoaderInit:
         assert "source_bytes" not in op.template_fields
 
     def test_both_sources_raises(self):
-        op = DocumentLoaderOperator(task_id="test", 
source_path="/tmp/file.txt", source_bytes=b"hello")
+        # source_path/source_bytes provision is a constructor-time check now.
         with pytest.raises(ValueError, match="not both"):
-            op.execute(context={})
+            DocumentLoaderOperator(task_id="test", 
source_path="/tmp/file.txt", source_bytes=b"hello")
 
     def test_neither_source_raises(self):
-        op = DocumentLoaderOperator(task_id="test")
         with pytest.raises(ValueError, match="Provide exactly one"):
-            op.execute(context={})
+            DocumentLoaderOperator(task_id="test")
 
     def test_source_bytes_without_file_type_raises(self):
+        # file_type is a template field, so this check only fires at execute() 
time.
         op = DocumentLoaderOperator(task_id="test", source_bytes=b"hello")
         with pytest.raises(ValueError, match="file_type"):
             op.execute(context={})
@@ -69,6 +69,14 @@ class TestDocumentLoaderInit:
         with pytest.raises(ValueError, match="file_type"):
             op.execute(context={})
 
+    def test_source_path_none_after_render_raises(self):
+        # source_path can render to None even when supplied -- must raise 
ValueError,
+        # not a TypeError from _resolve_files.
+        op = DocumentLoaderOperator(task_id="test", source_path="{{ none }}")
+        op.source_path = None  # simulate the rendered value, bypassing real 
templating
+        with pytest.raises(ValueError, match="Provide exactly one"):
+            op.execute(context={})
+
 
 class TestTextParser:
     def test_txt_file(self, tmp_path):

Reply via email to