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

kaxil 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 c6b151d3953 Fix `DocumentLoaderOperator` validation errors naming the 
wrong argument (#73053)
c6b151d3953 is described below

commit c6b151d39531ff5edd3a50b53fdddf4f1b14eb52
Author: Kaxil Naik <[email protected]>
AuthorDate: Sun Sep 13 14:02:23 2026 +0100

    Fix `DocumentLoaderOperator` validation errors naming the wrong argument 
(#73053)
    
    * Fix DocumentLoaderOperator validation errors naming the wrong argument
    
    Two of the operator's argument checks reported something other than what
    actually went wrong.
    
    `file_type` is required with `source_bytes`, and whether it was supplied is
    knowable without rendering, so it now raises in `__init__` alongside the
    `source_path`/`source_bytes` pair rather than waiting for the task to run.
    
    The two checks that remain in `execute()` are reached only when a supplied
    template field rendered to None, but both reported provision failures. A 
user
    who passed `source_path="{{ ... }}"` that rendered away was told to "Provide
    exactly one of 'source_path' or 'source_bytes'" -- advice they had already
    followed. Both messages now name the field that rendered to None.
    
    The comment above those checks claimed provision "already happened in
    __init__" for both fields, which was never true of `file_type`. #70503 makes
    this file the reference other operators copy, so the comment now describes 
the
    split it actually implements.
    
    The rendered-to-None test drove the state by assigning the attribute 
directly,
    which passes whether or not the field is still rendered. It now goes through
    real templating under `render_template_as_native_obj`, and the 
template-fields
    test renders instead of asserting tuple membership.
    
    * Qualify the file_type parse-time claim for mapped tasks
    
    expand() validates argument names only and defers construction to unmap(),
    which runs on the worker, so the check is a run-time error for a mapped 
task.
---
 .../common/ai/operators/document_loader.py         | 31 ++++++----
 .../common/ai/operators/test_document_loader.py    | 69 +++++++++++++---------
 2 files changed, 63 insertions(+), 37 deletions(-)

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 f38d9c75ab0..c426f104758 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
@@ -75,9 +75,12 @@ class DocumentLoaderOperator(BaseOperator):
         ``ObjectStoragePath`` for cloud URIs (``aws_default``,
         ``google_cloud_default``, ...). Ignored for local paths.
     :param source_bytes: Raw file bytes, typically from XCom.
-    :param file_type: File extension hint when using ``source_bytes``
-        (e.g. ``".pdf"``). Also accepted with ``source_path`` to override
-        auto-detection.
+    :param file_type: File extension hint (e.g. ``".pdf"``). Required when
+        using ``source_bytes``, since bytes carry no extension to detect.
+        Omitting it is rejected when the operator is constructed -- Dag parse
+        time for a regular task, run time for a mapped one, since ``expand()``
+        validates argument names only and defers construction to ``unmap()``.
+        Optional with ``source_path``, where it overrides auto-detection.
     :param parser: Parsing backend selection. ``"auto"`` (default) picks the
         backend from the file extension.
     :param file_extensions: When ``source_path`` is a directory or glob,
@@ -140,6 +143,8 @@ class DocumentLoaderOperator(BaseOperator):
             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'.")
+        if source_bytes is not None and file_type is None:
+            raise ValueError("'file_type' is required when using 
'source_bytes' (e.g. '.pdf').")
         self.source_path = source_path
         self.source_conn_id = source_conn_id
         self.source_bytes = source_bytes
@@ -152,15 +157,21 @@ class DocumentLoaderOperator(BaseOperator):
         self.json_text_field = json_text_field
 
     def execute(self, context: Context) -> list[dict[str, Any]]:
-        # 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.
+        # Provision -- whether an argument was supplied at all -- is settled 
in __init__.
+        # Both guards below exist for a different reason: file_type and 
source_path are
+        # template fields, so a supplied argument can still arrive here as 
None once it has
+        # been rendered. __init__ cannot catch that, because it only ever sees 
the unrendered
+        # template string; _parse_bytes and _resolve_files need the rendered 
value to be real.
         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').")
+            raise ValueError(
+                "'file_type' was supplied but rendered to None. Check the 
template or the "
+                "upstream XCom value it resolves from."
+            )
         if self.source_bytes is None and self.source_path is None:
-            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes'.")
+            raise ValueError(
+                "'source_path' was supplied but rendered to None. Check the 
template or the "
+                "upstream XCom value it resolves from."
+            )
 
         if self.source_bytes is not None:
             if TYPE_CHECKING:
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 111cf7738e5..19628d5c058 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
@@ -23,28 +23,26 @@ from unittest.mock import MagicMock, patch
 import pytest
 
 from airflow.providers.common.ai.operators.document_loader import 
DocumentLoaderOperator
+from airflow.sdk import DAG
 
 
 class TestDocumentLoaderInit:
     def test_template_fields_render_source_path_and_metadata(self):
-        """
-        Behavioral check that the templated fields actually get rendered.
-        Replaces the previous tautological assertion that just round-tripped
-        the class attribute.
-        """
+        """The templated fields are substituted by render_template_fields, not 
merely listed."""
         op = DocumentLoaderOperator(
             task_id="test",
             source_path="/data/{{ ds }}/*.pdf",
-            file_type="{{ var.value.preferred_ext }}",
+            file_type="{{ params.preferred_ext }}",
             metadata_fields={"run_id": "{{ run_id }}"},
         )
-        # Make sure each one is in template_fields so render_template_fields
-        # would substitute them.
-        assert "source_path" in op.template_fields
-        assert "file_type" in op.template_fields
-        assert "file_extensions" in op.template_fields
-        assert "parser" in op.template_fields
-        assert "metadata_fields" in op.template_fields
+
+        op.render_template_fields(
+            context={"ds": "2026-01-01", "run_id": "manual__1", "params": 
{"preferred_ext": ".pdf"}}
+        )
+
+        assert op.source_path == "/data/2026-01-01/*.pdf"
+        assert op.file_type == ".pdf"
+        assert op.metadata_fields == {"run_id": "manual__1"}
         # source_bytes intentionally not templated -- Jinja stringifies bytes
         # to their repr, which would break binary parsing.
         assert "source_bytes" not in op.template_fields
@@ -58,23 +56,40 @@ class TestDocumentLoaderInit:
         with pytest.raises(ValueError, match="Provide exactly one"):
             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={})
+    def test_source_bytes_without_file_type_raises_at_construction(self):
+        # Whether file_type was supplied at all is knowable without rendering, 
so it is a
+        # Dag-parse-time error like the source_path/source_bytes pair above it.
+        with pytest.raises(ValueError, match="'file_type' is required"):
+            DocumentLoaderOperator(task_id="test", source_bytes=b"hello")
+
+    def test_empty_bytes_without_file_type_raises_at_construction(self):
+        with pytest.raises(ValueError, match="'file_type' is required"):
+            DocumentLoaderOperator(task_id="test", source_bytes=b"")
 
-    def test_empty_bytes_without_file_type_raises(self):
-        op = DocumentLoaderOperator(task_id="test", source_bytes=b"")
-        with pytest.raises(ValueError, match="file_type"):
+    def test_source_path_rendering_to_none_raises(self):
+        """A supplied source_path that renders to None raises ValueError, not 
TypeError.
+
+        Driven through real templating rather than by assigning the attribute, 
so the test
+        fails if the field ever stops being rendered.
+        """
+        with DAG(dag_id="native", schedule=None, 
render_template_as_native_obj=True):
+            op = DocumentLoaderOperator(task_id="test", source_path="{{ none 
}}")
+
+        op.render_template_fields(context={})
+        assert op.source_path is None
+
+        with pytest.raises(ValueError, match="'source_path' was supplied but 
rendered to None"):
             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"):
+    def test_file_type_rendering_to_none_raises(self):
+        """file_type passes the __init__ guard when supplied, then renders 
away to None."""
+        with DAG(dag_id="native", schedule=None, 
render_template_as_native_obj=True):
+            op = DocumentLoaderOperator(task_id="test", source_bytes=b"hello", 
file_type="{{ none }}")
+
+        op.render_template_fields(context={})
+        assert op.file_type is None
+
+        with pytest.raises(ValueError, match="'file_type' was supplied but 
rendered to None"):
             op.execute(context={})
 
 

Reply via email to