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

guan404ming 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 1e0f48ce6d0 Support bzip2 and xz compressed inputs in LLM file 
analysis (#70302)
1e0f48ce6d0 is described below

commit 1e0f48ce6d08e537e4e4583445e7df2a9225bd88
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Thu Aug 27 20:14:24 2026 +0800

    Support bzip2 and xz compressed inputs in LLM file analysis (#70302)
    
    * Support bzip2 and xz compressed inputs in LLM file analysis
    
    * Guard optional bz2/lzma imports in LLM file analysis
    
    * Surface missing codec modules and pin multi-stream behavior
    
    * Fix spelling check for xz in LLM file analysis docs
---
 .../common/ai/docs/operators/llm_file_analysis.rst | 16 ++++-
 .../providers/common/ai/utils/file_analysis.py     | 39 +++++++---
 .../unit/common/ai/utils/test_file_analysis.py     | 83 +++++++++++++++++++---
 3 files changed, 117 insertions(+), 21 deletions(-)

diff --git a/providers/common/ai/docs/operators/llm_file_analysis.rst 
b/providers/common/ai/docs/operators/llm_file_analysis.rst
index bb019e2b6a0..ac688ebfaf4 100644
--- a/providers/common/ai/docs/operators/llm_file_analysis.rst
+++ b/providers/common/ai/docs/operators/llm_file_analysis.rst
@@ -167,9 +167,19 @@ Supported Formats
 
 - Text-like: ``.log``, ``.txt``, ``.md``, ``.json``, ``.csv``, ``.parquet``, 
``.avro``
 - Multimodal: ``.png``, ``.jpg``, ``.jpeg``, ``.pdf`` when ``multi_modal=True``
-- Gzip-compressed text inputs are supported for ``.log.gz``, ``.json.gz``, and
-  ``.csv.gz``, ``.txt.gz``, and ``.md.gz``.
-- Gzip is not supported for ``.parquet``, ``.avro``, image, or PDF inputs.
+- ``gzip``, ``bzip2``, and ``xz`` compressed text inputs are supported for
+  ``.log``, ``.json``, ``.csv``, ``.txt``, and ``.md`` (``.log.gz``, 
``.csv.bz2``,
+  ``.json.xz``, ...).
+  ``bzip2`` and ``xz`` need a Python interpreter built with the ``bz2`` and
+  ``lzma`` modules; otherwise those inputs raise
+  ``AirflowOptionalProviderFeatureException``.
+- Compression is not supported for ``.parquet``, ``.avro``, image, or PDF
+  inputs.
+- For ``bzip2`` and ``xz``, concatenated streams are read in full, but any
+  data after a point that does not start a valid stream (for example ``xz``
+  Stream Padding between streams, or trailing garbage) is silently ignored,
+  so only the content up to that point is analyzed. ``gzip`` rejects such
+  files instead (``BadGzipFile``).
 
 Parquet and Avro readers require their corresponding optional extras:
 
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/utils/file_analysis.py 
b/providers/common/ai/src/airflow/providers/common/ai/utils/file_analysis.py
index 6f6366b515e..f53a23fa63a 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/file_analysis.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/file_analysis.py
@@ -28,6 +28,17 @@ from dataclasses import dataclass
 from pathlib import PurePosixPath
 from typing import TYPE_CHECKING, Any
 
+# bz2/lzma are optional CPython extensions and may be missing from some 
interpreter builds
+try:
+    import bz2
+except ImportError:
+    bz2 = None  # type: ignore[assignment]
+
+try:
+    import lzma
+except ImportError:
+    lzma = None  # type: ignore[assignment]
+
 from pydantic_ai.messages import BinaryContent
 
 from airflow.providers.common.ai.exceptions import (
@@ -38,7 +49,7 @@ from airflow.providers.common.ai.exceptions import (
 from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException, ObjectStoragePath
 
 if TYPE_CHECKING:
-    from collections.abc import Sequence
+    from collections.abc import Callable, Sequence
 
     from pydantic_ai.messages import UserContent
 
@@ -65,7 +76,14 @@ _COMPRESSION_SUFFIXES = {
     "xz": "xz",
     "zst": "zstd",
 }
-_GZIP_SUPPORTED_FORMATS = frozenset({"csv", "json", "log", "txt", "md"})
+_CODEC_MODULES = {"bzip2": "bz2", "xz": "lzma"}
+_KNOWN_CODECS = frozenset({"gzip", *_CODEC_MODULES})
+_DECOMPRESSORS: dict[str, Callable[..., io.BufferedIOBase]] = {"gzip": 
gzip.open}
+if bz2 is not None:
+    _DECOMPRESSORS["bzip2"] = bz2.open
+if lzma is not None:
+    _DECOMPRESSORS["xz"] = lzma.open
+_COMPRESSION_SUPPORTED_FORMATS = frozenset({"csv", "json", "log", "txt", "md"})
 _TEXT_SAMPLE_HEAD_CHARS = 8_000
 _TEXT_SAMPLE_TAIL_CHARS = 2_000
 _MEDIA_TYPES = {
@@ -371,15 +389,20 @@ def detect_file_format(path: ObjectStoragePath) -> 
tuple[str, str | None]:
         raise LLMFileAnalysisUnsupportedFormatError(
             f"Unsupported file format {detected!r} for {path}. Supported 
formats: {', '.join(SUPPORTED_FILE_FORMATS)}."
         )
-    if compression and compression != "gzip":
+    if compression and compression not in _KNOWN_CODECS:
         log.info("Rejecting file %s because compression=%s is not supported.", 
path, compression)
         raise LLMFileAnalysisUnsupportedFormatError(
             f"Compression {compression!r} is not supported for file analysis."
         )
-    if compression == "gzip" and detected not in _GZIP_SUPPORTED_FORMATS:
+    if compression and detected not in _COMPRESSION_SUPPORTED_FORMATS:
         raise LLMFileAnalysisUnsupportedFormatError(
             f"Compression {compression!r} is not supported for {detected!r} 
file analysis."
         )
+    if compression and compression not in _DECOMPRESSORS:
+        raise AirflowOptionalProviderFeatureException(
+            f"Compression {compression!r} requires the 
{_CODEC_MODULES[compression]!r} module, "
+            "which is missing from this Python build."
+        )
     return detected, compression
 
 
@@ -537,10 +560,10 @@ def _render_avro(path: ObjectStoragePath, *, sample_rows: 
int, max_content_bytes
 
 def _read_raw_bytes(path: ObjectStoragePath, *, compression: str | None, 
max_bytes: int) -> bytes:
     with path.open("rb") as handle:
-        if compression == "gzip":
-            with gzip.GzipFile(fileobj=handle) as gzip_handle:
-                return _read_limited_bytes(gzip_handle, path=path, 
max_bytes=max_bytes)
-        return _read_limited_bytes(handle, path=path, max_bytes=max_bytes)
+        if compression is None:
+            return _read_limited_bytes(handle, path=path, max_bytes=max_bytes)
+        with _DECOMPRESSORS[compression](handle) as decompressed:
+            return _read_limited_bytes(decompressed, path=path, 
max_bytes=max_bytes)
 
 
 def _read_limited_bytes(handle: io.BufferedIOBase, *, path: ObjectStoragePath, 
max_bytes: int) -> bytes:
diff --git 
a/providers/common/ai/tests/unit/common/ai/utils/test_file_analysis.py 
b/providers/common/ai/tests/unit/common/ai/utils/test_file_analysis.py
index bba7f56111a..b2a80186562 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_file_analysis.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_file_analysis.py
@@ -30,6 +30,7 @@ from airflow.providers.common.ai.exceptions import (
     LLMFileAnalysisUnsupportedFormatError,
 )
 from airflow.providers.common.ai.utils.file_analysis import (
+    _DECOMPRESSORS,
     FileAnalysisRequest,
     _infer_partitions,
     _read_raw_bytes,
@@ -278,9 +279,14 @@ class TestBuildFileAnalysisRequest:
 
         mock_prepare.assert_not_called()
 
-    def test_gzip_expansion_respects_processed_content_limit(self, tmp_path):
-        path = tmp_path / "big.log.gz"
-        path.write_bytes(gzip.compress(b"A" * 20_000))
+    @pytest.mark.parametrize(
+        ("suffix", "module_name"),
+        [("gz", "gzip"), ("bz2", "bz2"), ("xz", "lzma")],
+    )
+    def test_compressed_expansion_respects_processed_content_limit(self, 
tmp_path, suffix, module_name):
+        codec = pytest.importorskip(module_name)
+        path = tmp_path / f"big.log.{suffix}"
+        path.write_bytes(codec.compress(b"A" * 20_000))
 
         with pytest.raises(LLMFileAnalysisLimitExceededError, 
match="processed-content limit"):
             build_file_analysis_request(
@@ -377,6 +383,8 @@ class TestFileAnalysisHelpers:
         [
             ("events.csv", "csv", None),
             ("events.csv.gz", "csv", "gzip"),
+            ("events.csv.bz2", "csv", "bzip2"),
+            ("events.json.xz", "json", "xz"),
             ("dashboard.jpg", "jpg", None),
             ("report.pdf", "pdf", None),
             ("app", "log", None),
@@ -402,22 +410,77 @@ class TestFileAnalysisHelpers:
         with pytest.raises(LLMFileAnalysisUnsupportedFormatError, 
match="Compression"):
             detect_file_format(ObjectStoragePath(str(path)))
 
-    @pytest.mark.parametrize("filename", ["sample.parquet.gz", 
"sample.avro.gz", "sample.png.gz"])
-    def 
test_detect_file_format_rejects_unsupported_gzip_format_combinations(self, 
tmp_path, filename):
+    @pytest.mark.parametrize(
+        ("filename", "codec", "module_name"),
+        [("events.csv.bz2", "bzip2", "bz2"), ("events.json.xz", "xz", "lzma")],
+    )
+    def test_detect_file_format_without_codec_module(self, tmp_path, filename, 
codec, module_name):
+        path = tmp_path / filename
+        path.write_bytes(b"content")
+        gz_path = tmp_path / "events.csv.gz"
+        gz_path.write_bytes(b"content")
+        plain_path = tmp_path / "events.csv"
+        plain_path.write_bytes(b"content")
+
+        with patch.dict(_DECOMPRESSORS, {"gzip": gzip.open}, clear=True):
+            with pytest.raises(
+                AirflowOptionalProviderFeatureException,
+                match=f"Compression '{codec}' requires the '{module_name}' 
module",
+            ):
+                detect_file_format(ObjectStoragePath(str(path)))
+            assert detect_file_format(ObjectStoragePath(str(gz_path))) == 
("csv", "gzip")
+            assert detect_file_format(ObjectStoragePath(str(plain_path))) == 
("csv", None)
+
+    @pytest.mark.parametrize(
+        "filename", ["sample.parquet.gz", "sample.avro.bz2", "sample.png.xz", 
"sample.pdf.gz"]
+    )
+    def 
test_detect_file_format_rejects_unsupported_compression_format_combinations(self,
 tmp_path, filename):
         path = tmp_path / filename
         path.write_bytes(b"content")
 
-        with pytest.raises(LLMFileAnalysisUnsupportedFormatError, match="not 
supported for"):
+        with pytest.raises(
+            LLMFileAnalysisUnsupportedFormatError, match=r"not supported for 
'\w+' file analysis"
+        ):
             detect_file_format(ObjectStoragePath(str(path)))
 
-    def test_read_raw_bytes_decompresses_gzip(self, tmp_path):
-        path = tmp_path / "events.log.gz"
-        path.write_bytes(gzip.compress(b"line one\nline two\n"))
+    @pytest.mark.parametrize(
+        ("suffix", "compression", "module_name"),
+        [
+            ("gz", "gzip", "gzip"),
+            ("bz2", "bzip2", "bz2"),
+            ("xz", "xz", "lzma"),
+        ],
+    )
+    def test_read_raw_bytes_decompresses(self, tmp_path, suffix, compression, 
module_name):
+        codec = pytest.importorskip(module_name)
+        path = tmp_path / f"events.log.{suffix}"
+        path.write_bytes(codec.compress(b"line one\nline two\n"))
 
-        content = _read_raw_bytes(ObjectStoragePath(str(path)), 
compression="gzip", max_bytes=1_024)
+        content = _read_raw_bytes(ObjectStoragePath(str(path)), 
compression=compression, max_bytes=1_024)
 
         assert content == b"line one\nline two\n"
 
+    @pytest.mark.parametrize(
+        ("suffix", "compression", "module_name", "separator", "expected"),
+        [
+            ("bz2", "bzip2", "bz2", b"", b"first\nsecond\n"),
+            ("bz2", "bzip2", "bz2", b"GARBAGE", b"first\n"),
+            ("xz", "xz", "lzma", b"", b"first\nsecond\n"),
+            ("xz", "xz", "lzma", b"\x00\x00\x00\x00", b"first\n"),
+        ],
+        ids=["bz2-concatenated", "bz2-trailing-garbage", "xz-concatenated", 
"xz-stream-padding"],
+    )
+    def test_read_raw_bytes_multi_stream_behavior(
+        self, tmp_path, suffix, compression, module_name, separator, expected
+    ):
+        codec = pytest.importorskip(module_name)
+        path = tmp_path / f"events.log.{suffix}"
+        path.write_bytes(codec.compress(b"first\n") + separator + 
codec.compress(b"second\n"))
+
+        content = _read_raw_bytes(ObjectStoragePath(str(path)), 
compression=compression, max_bytes=1_024)
+
+        assert content == expected
+
     def test_truncate_text_preserves_head_and_tail(self):
         text = "A" * 9_000 + "B" * 3_000
 

Reply via email to