Skip to content

Commit c6b151d

Browse files
authored
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.
1 parent fab25d9 commit c6b151d

2 files changed

Lines changed: 63 additions & 37 deletions

File tree

‎providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ class DocumentLoaderOperator(BaseOperator):
7575
``ObjectStoragePath`` for cloud URIs (``aws_default``,
7676
``google_cloud_default``, ...). Ignored for local paths.
7777
:param source_bytes: Raw file bytes, typically from XCom.
78-
:param file_type: File extension hint when using ``source_bytes``
79-
(e.g. ``".pdf"``). Also accepted with ``source_path`` to override
80-
auto-detection.
78+
:param file_type: File extension hint (e.g. ``".pdf"``). Required when
79+
using ``source_bytes``, since bytes carry no extension to detect.
80+
Omitting it is rejected when the operator is constructed -- Dag parse
81+
time for a regular task, run time for a mapped one, since ``expand()``
82+
validates argument names only and defers construction to ``unmap()``.
83+
Optional with ``source_path``, where it overrides auto-detection.
8184
:param parser: Parsing backend selection. ``"auto"`` (default) picks the
8285
backend from the file extension.
8386
:param file_extensions: When ``source_path`` is a directory or glob,
@@ -140,6 +143,8 @@ def __init__(
140143
raise ValueError("Provide exactly one of 'source_path' or 'source_bytes', not both.")
141144
if source_path is None and source_bytes is None:
142145
raise ValueError("Provide exactly one of 'source_path' or 'source_bytes'.")
146+
if source_bytes is not None and file_type is None:
147+
raise ValueError("'file_type' is required when using 'source_bytes' (e.g. '.pdf').")
143148
self.source_path = source_path
144149
self.source_conn_id = source_conn_id
145150
self.source_bytes = source_bytes
@@ -152,15 +157,21 @@ def __init__(
152157
self.json_text_field = json_text_field
153158

154159
def execute(self, context: Context) -> list[dict[str, Any]]:
155-
# file_type and source_path can each be *supplied* (as non-None argument) yet still
156-
# render to None. These aren't provision checks (that already happened in __init__);
157-
# they guard the rendered value itself, since _parse_bytes/_resolve_files need a real
158-
# value to work with. Checking this in __init__ would validate the unrendered template
159-
# string instead of the value actually used here.
160+
# Provision -- whether an argument was supplied at all -- is settled in __init__.
161+
# Both guards below exist for a different reason: file_type and source_path are
162+
# template fields, so a supplied argument can still arrive here as None once it has
163+
# been rendered. __init__ cannot catch that, because it only ever sees the unrendered
164+
# template string; _parse_bytes and _resolve_files need the rendered value to be real.
160165
if self.source_bytes is not None and self.file_type is None:
161-
raise ValueError("'file_type' is required when using 'source_bytes' (e.g. '.pdf').")
166+
raise ValueError(
167+
"'file_type' was supplied but rendered to None. Check the template or the "
168+
"upstream XCom value it resolves from."
169+
)
162170
if self.source_bytes is None and self.source_path is None:
163-
raise ValueError("Provide exactly one of 'source_path' or 'source_bytes'.")
171+
raise ValueError(
172+
"'source_path' was supplied but rendered to None. Check the template or the "
173+
"upstream XCom value it resolves from."
174+
)
164175

165176
if self.source_bytes is not None:
166177
if TYPE_CHECKING:

‎providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py‎

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,28 +23,26 @@
2323
import pytest
2424

2525
from airflow.providers.common.ai.operators.document_loader import DocumentLoaderOperator
26+
from airflow.sdk import DAG
2627

2728

2829
class TestDocumentLoaderInit:
2930
def test_template_fields_render_source_path_and_metadata(self):
30-
"""
31-
Behavioral check that the templated fields actually get rendered.
32-
Replaces the previous tautological assertion that just round-tripped
33-
the class attribute.
34-
"""
31+
"""The templated fields are substituted by render_template_fields, not merely listed."""
3532
op = DocumentLoaderOperator(
3633
task_id="test",
3734
source_path="/data/{{ ds }}/*.pdf",
38-
file_type="{{ var.value.preferred_ext }}",
35+
file_type="{{ params.preferred_ext }}",
3936
metadata_fields={"run_id": "{{ run_id }}"},
4037
)
41-
# Make sure each one is in template_fields so render_template_fields
42-
# would substitute them.
43-
assert "source_path" in op.template_fields
44-
assert "file_type" in op.template_fields
45-
assert "file_extensions" in op.template_fields
46-
assert "parser" in op.template_fields
47-
assert "metadata_fields" in op.template_fields
38+
39+
op.render_template_fields(
40+
context={"ds": "2026-01-01", "run_id": "manual__1", "params": {"preferred_ext": ".pdf"}}
41+
)
42+
43+
assert op.source_path == "/data/2026-01-01/*.pdf"
44+
assert op.file_type == ".pdf"
45+
assert op.metadata_fields == {"run_id": "manual__1"}
4846
# source_bytes intentionally not templated -- Jinja stringifies bytes
4947
# to their repr, which would break binary parsing.
5048
assert "source_bytes" not in op.template_fields
@@ -58,23 +56,40 @@ def test_neither_source_raises(self):
5856
with pytest.raises(ValueError, match="Provide exactly one"):
5957
DocumentLoaderOperator(task_id="test")
6058

61-
def test_source_bytes_without_file_type_raises(self):
62-
# file_type is a template field, so this check only fires at execute() time.
63-
op = DocumentLoaderOperator(task_id="test", source_bytes=b"hello")
64-
with pytest.raises(ValueError, match="file_type"):
65-
op.execute(context={})
59+
def test_source_bytes_without_file_type_raises_at_construction(self):
60+
# Whether file_type was supplied at all is knowable without rendering, so it is a
61+
# Dag-parse-time error like the source_path/source_bytes pair above it.
62+
with pytest.raises(ValueError, match="'file_type' is required"):
63+
DocumentLoaderOperator(task_id="test", source_bytes=b"hello")
64+
65+
def test_empty_bytes_without_file_type_raises_at_construction(self):
66+
with pytest.raises(ValueError, match="'file_type' is required"):
67+
DocumentLoaderOperator(task_id="test", source_bytes=b"")
6668

67-
def test_empty_bytes_without_file_type_raises(self):
68-
op = DocumentLoaderOperator(task_id="test", source_bytes=b"")
69-
with pytest.raises(ValueError, match="file_type"):
69+
def test_source_path_rendering_to_none_raises(self):
70+
"""A supplied source_path that renders to None raises ValueError, not TypeError.
71+
72+
Driven through real templating rather than by assigning the attribute, so the test
73+
fails if the field ever stops being rendered.
74+
"""
75+
with DAG(dag_id="native", schedule=None, render_template_as_native_obj=True):
76+
op = DocumentLoaderOperator(task_id="test", source_path="{{ none }}")
77+
78+
op.render_template_fields(context={})
79+
assert op.source_path is None
80+
81+
with pytest.raises(ValueError, match="'source_path' was supplied but rendered to None"):
7082
op.execute(context={})
7183

72-
def test_source_path_none_after_render_raises(self):
73-
# source_path can render to None even when supplied -- must raise ValueError,
74-
# not a TypeError from _resolve_files.
75-
op = DocumentLoaderOperator(task_id="test", source_path="{{ none }}")
76-
op.source_path = None # simulate the rendered value, bypassing real templating
77-
with pytest.raises(ValueError, match="Provide exactly one"):
84+
def test_file_type_rendering_to_none_raises(self):
85+
"""file_type passes the __init__ guard when supplied, then renders away to None."""
86+
with DAG(dag_id="native", schedule=None, render_template_as_native_obj=True):
87+
op = DocumentLoaderOperator(task_id="test", source_bytes=b"hello", file_type="{{ none }}")
88+
89+
op.render_template_fields(context={})
90+
assert op.file_type is None
91+
92+
with pytest.raises(ValueError, match="'file_type' was supplied but rendered to None"):
7893
op.execute(context={})
7994

8095

0 commit comments

Comments
 (0)