This is an automated email from the ASF dual-hosted git repository.
potiuk 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 f0e82be3163 Add json line support for DocumentLoaderOperator (#72246)
f0e82be3163 is described below
commit f0e82be31634fd784abce417f4b89f31ae1af4ae
Author: Jeff(Wei-Hao) Lu <[email protected]>
AuthorDate: Wed Sep 23 07:07:45 2026 +0800
Add json line support for DocumentLoaderOperator (#72246)
* add jsonl support
* fix mypy
* raise error with correct line
* Split JSON Lines on newline rather than splitlines()
str.splitlines() also breaks on U+2028, U+2029 and U+0085, which are legal
unescaped characters inside a JSON string (JSON only forbids raw
U+0000-U+001F
there). A valid record containing one was torn in half, reported as invalid,
and every later line number was shifted -- which matters because scraped web
text is a common input for this operator. A trailing \r from a CRLF file is
JSON whitespace, so json.loads still tolerates it.
Generated-by: Claude Opus 5
* add source hint to make error msg contain file path
---------
Co-authored-by: jeff3071 <[email protected]>
---
.../common/ai/docs/operators/document_loader.rst | 35 +++++------
.../common/ai/operators/document_loader.py | 52 +++++++++++++----
.../common/ai/operators/test_document_loader.py | 67 ++++++++++++++++++++++
3 files changed, 126 insertions(+), 28 deletions(-)
diff --git a/providers/common/ai/docs/operators/document_loader.rst
b/providers/common/ai/docs/operators/document_loader.rst
index a699304ecc3..600831f36ec 100644
--- a/providers/common/ai/docs/operators/document_loader.rst
+++ b/providers/common/ai/docs/operators/document_loader.rst
@@ -32,8 +32,8 @@ LangChain, or any other AI framework.
Basic usage
-----------
-``.txt``, ``.md``, ``.csv``, and ``.json`` are handled with zero extra
-dependencies:
+``.txt``, ``.md``, ``.csv``, ``.json``, and ``.jsonl`` are handled with zero
+extra dependencies:
.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_document_loader.py
:language: python
@@ -45,6 +45,9 @@ with a top-level array produce one document per element; a
single JSON object
produces one document. By default each dict is flattened into ``"key: value,
key: value"`` text so the embedding sees content tokens rather than JSON
syntax (see the ``json_text_field`` section below for the structured variant).
+JSON Lines files produce one document per non-empty line. A JSON array on a
+single line remains one document rather than being expanded, and
+``json_text_field`` applies to each line just as it does to JSON records.
PDF parsing
-----------
@@ -153,8 +156,8 @@ Format coverage roadmap
-----------------------
The current built-in dispatch covers ``.txt``, ``.md``, ``.csv``, ``.json``,
-``.pdf``, ``.docx``. Additional formats are deferred to follow-ups, each
-gated behind its own extra so users only install what they need:
+``.jsonl``, ``.pdf``, ``.docx``. Additional formats are deferred to follow-ups,
+each gated behind its own extra so users only install what they need:
- ``.pptx`` via ``python-pptx``
- ``.epub`` via ``ebooklib``
@@ -233,13 +236,13 @@ download-then-parse pattern still works:
Non-UTF-8 inputs
----------------
-The text parsers (``.txt`` / ``.md`` / ``.csv`` / ``.json``) and the bytes
-path default to UTF-8. To handle Windows-1252 CSVs, files with a leading
-``utf-8-sig`` byte-order mark, or any other encoding, set the ``encoding``
-parameter on the operator (and optionally ``encoding_errors="replace"`` to
-tolerate mixed-encoding sources at the cost of some character loss). A
-failed decode includes the offending file path in the error so
-directory-mode runs are easy to diagnose.
+The text parsers (``.txt`` / ``.md`` / ``.csv`` / ``.json`` / ``.jsonl``) and
+the bytes path default to UTF-8. To handle Windows-1252 CSVs, files with a
+leading ``utf-8-sig`` byte-order mark, or any other encoding, set the
+``encoding`` parameter on the operator (and optionally
+``encoding_errors="replace"`` to tolerate mixed-encoding sources at the cost
+of some character loss). A failed decode includes the offending file path in
+the error so directory-mode runs are easy to diagnose.
Metadata precedence
-------------------
@@ -287,12 +290,12 @@ Parameters
not override auto-extracted keys.
* - ``encoding``
- Text encoding for the bytes path and ``.txt`` / ``.md`` / ``.csv`` /
- ``.json`` files. Defaults to ``"utf-8"``.
+ ``.json`` / ``.jsonl`` files. Defaults to ``"utf-8"``.
* - ``encoding_errors``
- How decode errors are handled (``"strict"`` / ``"replace"`` /
``"ignore"``). Defaults to ``"strict"``.
* - ``json_text_field``
- - When parsing JSON, treat this key as the embedding text; every other
- key on the same item lands in ``metadata``. When unset, dicts are
- flattened to ``"k: v, k: v"`` so the embedding sees content tokens
- rather than JSON syntax.
+ - When parsing JSON or JSON Lines, treat this key as the embedding text;
+ every other key on the same item lands in ``metadata``. When unset,
+ dicts are flattened to ``"k: v, k: v"`` so the embedding sees content
+ tokens rather than JSON syntax.
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 c426f104758..42f630ffcf5 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
@@ -50,9 +50,9 @@ class DocumentLoaderOperator(BaseOperator):
with metadata). Framework-agnostic: no LlamaIndex, LangChain, or other
AI framework dependency.
- Built-in parsers handle ``.txt``, ``.md``, ``.csv``, and ``.json`` with
- zero extra dependencies. PDF and DOCX support require optional packages
- installable via extras::
+ Built-in parsers handle ``.txt``, ``.md``, ``.csv``, ``.json``, and
+ ``.jsonl`` with zero extra dependencies. PDF and DOCX support require
+ optional packages installable via extras::
pip install apache-airflow-providers-common-ai[pdf] # pypdf
pip install apache-airflow-providers-common-ai[docx] # python-docx
@@ -92,17 +92,18 @@ class DocumentLoaderOperator(BaseOperator):
document's ``metadata`` dict. Auto-extracted fields such as
``file_name``, ``file_path``, ``row_index``, ``item_index``, and
``page_number`` take precedence over keys with the same name.
- :param encoding: Text encoding used for ``.txt``/``.md``/``.csv``/``.json``
- and for the bytes path. Defaults to ``"utf-8"``.
+ :param encoding: Text encoding used for
+ ``.txt``/``.md``/``.csv``/``.json``/``.jsonl`` and for the bytes path.
+ Defaults to ``"utf-8"``.
:param encoding_errors: How decode errors are handled. Defaults to
``"strict"``; set to ``"replace"`` or ``"ignore"`` to tolerate
mixed-encoding inputs at the cost of some character loss.
- :param json_text_field: When parsing JSON, treat this key as the
- embedding text and put every other key into ``metadata``. Applies
- to each item when the top-level JSON is a list, or to the object
- when it is a single dict. When ``None`` (default), the operator
- flattens dicts into ``"k: v, k: v"`` text (same shape as the CSV
- parser).
+ :param json_text_field: When parsing JSON or JSON Lines, treat this key
+ as the embedding text and put every other key into ``metadata``.
+ Applies to each item when the top-level JSON is a list, to the object
+ when it is a single dict, or to each JSON Lines record. When ``None``
+ (default), the operator flattens dicts into ``"k: v, k: v"`` text
+ (same shape as the CSV parser).
"""
template_fields: Sequence[str] = (
@@ -119,6 +120,7 @@ class DocumentLoaderOperator(BaseOperator):
".md": "text",
".csv": "csv",
".json": "json",
+ ".jsonl": "jsonl",
".pdf": "pypdf",
".docx": "python-docx",
}
@@ -296,11 +298,14 @@ class DocumentLoaderOperator(BaseOperator):
if backend == "python-docx":
return self._parse_docx_stream(io.BytesIO(raw))
- text = self._decode(raw, source_hint=f"<bytes:{ext}>")
+ source_hint = f"<bytes:{ext}>"
+ text = self._decode(raw, source_hint=source_hint)
if backend == "csv":
return self._parse_csv_text(text)
if backend == "json":
return self._parse_json_text(text)
+ if backend == "jsonl":
+ return self._parse_json_lines_text(text, source_hint=source_hint)
return [{"text": text, "metadata": {}}]
def _parse_file(self, file_path: Path, ext: str) -> list[dict[str, Any]]:
@@ -312,6 +317,8 @@ class DocumentLoaderOperator(BaseOperator):
return self._parse_csv(file_path)
if backend == "json":
return self._parse_json(file_path)
+ if backend == "jsonl":
+ return self._parse_json_lines(file_path)
if backend == "pypdf":
with file_path.open("rb") as fh:
return self._parse_pdf_stream(fh)
@@ -373,6 +380,27 @@ class DocumentLoaderOperator(BaseOperator):
return [self._json_item_to_doc(item, item_index=idx) for idx, item
in enumerate(data)]
return [self._json_item_to_doc(data, item_index=None)]
+ def _parse_json_lines(self, file_path: Path) -> list[dict[str, Any]]:
+ return self._parse_json_lines_text(self._read_text(file_path),
source_hint=str(file_path))
+
+ def _parse_json_lines_text(self, text: str, *, source_hint: str) ->
list[dict[str, Any]]:
+ documents: list[dict[str, Any]] = []
+ # split("\n") rather than splitlines(): JSON Lines is defined with \n,
while splitlines()
+ # also breaks on U+2028/U+2029/U+0085, which are legal unescaped
characters inside a JSON
+ # string and would tear a valid record in half. A trailing \r from
CRLF is JSON whitespace.
+ for line_number, line in enumerate(text.split("\n"), start=1):
+ if not line.strip():
+ continue
+ try:
+ item = json.loads(line)
+ except json.JSONDecodeError as e:
+ raise ValueError(
+ f"Failed to parse {source_hint}: "
+ f"invalid JSON on line {line_number}, column {e.colno}:
{e.msg}"
+ ) from e
+ documents.append(self._json_item_to_doc(item,
item_index=len(documents)))
+ return documents
+
def _json_item_to_doc(self, item: Any, *, item_index: int | None) ->
dict[str, Any]:
metadata: dict[str, Any] = {}
if item_index is not None:
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 19628d5c058..60031e3edfb 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
@@ -215,6 +215,73 @@ class TestJsonParser:
assert len(result) == 2
+class TestJsonLinesParser:
+ def test_one_document_per_non_empty_line(self, tmp_path):
+ f = tmp_path / "items.jsonl"
+ f.write_text('{"title": "First"}\n\n[1, 2]\n', encoding="utf-8")
+
+ op = DocumentLoaderOperator(task_id="test", source_path=str(f))
+ result = op.execute(context=MagicMock())
+
+ assert len(result) == 2
+ assert result[0]["text"] == "title: First"
+ assert result[1]["text"] == "[1, 2]"
+ assert [doc["metadata"]["item_index"] for doc in result] == [0, 1]
+
+ def test_source_bytes_uses_json_text_field(self):
+ raw = b'{"body": "First", "source": "a"}\n{"body": "Second", "source":
"b"}\n'
+ op = DocumentLoaderOperator(
+ task_id="test",
+ source_bytes=raw,
+ file_type=".jsonl",
+ json_text_field="body",
+ )
+ result = op.execute(context=MagicMock())
+
+ assert [doc["text"] for doc in result] == ["First", "Second"]
+ assert [doc["metadata"]["source"] for doc in result] == ["a", "b"]
+
+ def test_invalid_json_line(self):
+ raw = b'{"valid": true}\n\n{"invalid": }\n'
+ op = DocumentLoaderOperator(task_id="test", source_bytes=raw,
file_type=".jsonl")
+
+ with pytest.raises(
+ ValueError, match=r"Failed to parse <bytes:\.jsonl>: invalid JSON
on line 3, column 13"
+ ):
+ op.execute(context=MagicMock())
+
+ def test_invalid_json_line_in_file_names_source(self, tmp_path):
+ f = tmp_path / "invalid.jsonl"
+ f.write_text('{"valid": true}\n{"invalid": }\n', encoding="utf-8")
+ op = DocumentLoaderOperator(task_id="test", source_path=str(f))
+
+ with pytest.raises(ValueError, match=r"invalid JSON on line 2, column
13") as exc_info:
+ op.execute(context=MagicMock())
+
+ assert str(f) in str(exc_info.value)
+
+ def test_line_separator_inside_string_is_not_a_record_break(self):
+ # U+2028 is a legal unescaped character inside a JSON string, but
str.splitlines()
+ # breaks on it, which would tear this record in two and shift every
later line number.
+ raw = '{"a": "x\u2028y"}\n{"b": 1}\n'.encode()
+ op = DocumentLoaderOperator(task_id="test", source_bytes=raw,
file_type=".jsonl")
+
+ result = op.execute(context=MagicMock())
+
+ assert len(result) == 2
+ assert result[0]["text"] == "a: x\u2028y"
+ assert result[1]["text"] == "b: 1"
+
+ def test_directory_recognizes_jsonl_extension(self, tmp_path):
+ (tmp_path / "items.jsonl").write_text('{"name": "kept"}\n',
encoding="utf-8")
+
+ op = DocumentLoaderOperator(task_id="test", source_path=str(tmp_path))
+ result = op.execute(context=MagicMock())
+
+ assert len(result) == 1
+ assert result[0]["text"] == "name: kept"
+
+
def _make_mock_pypdf_module(mock_reader):
"""Create a fake pypdf module with a PdfReader that returns mock_reader."""
mock_module = MagicMock()