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

cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new f4cb7afa3e69 [SPARK-57705][SQL] Read CSV, JSON, text, and XML files 
from zip archives
f4cb7afa3e69 is described below

commit f4cb7afa3e69546a26031600ba599f7d75d85c1f
Author: akshatshenoi-db <[email protected]>
AuthorDate: Mon Jun 29 10:36:54 2026 +0800

    [SPARK-57705][SQL] Read CSV, JSON, text, and XML files from zip archives
    
    ### What changes were proposed in this pull request?
    
    Add zip (`.zip`) support to the streaming archive reader (`ArchiveReader`), 
extending the existing tar support (`.tar`/`.tar.gz`/`.tgz`) and continuing the 
archive-reader series: SPARK-57135 (CSV read), SPARK-57321 (CSV inference), 
SPARK-57419 (JSON), SPARK-57478 (text), SPARK-57479 (XML), SPARK-57481 (Avro).
    
    The archive read/inference integration is format-agnostic -- every data 
source dispatches through `ArchiveReader.isArchivePath` and 
`ArchiveReader(path).readEntries(...)` -- so zip works for every data source 
already wired up (CSV, JSON, text, XML, Avro) with no per-data-source changes.
    
    The change is concentrated in `ArchiveReader`. The entry-streaming engine 
(lazy one-entry-at-a-time advance, directory/dotfile/marker skipping with the 
same `ignoredPathSegmentRegex` filter as a loose-file listing, close-shielded 
entry streams, eager-first-entry error surfacing, task-completion cleanup) is 
hoisted from `TarArchiveReader` into the abstract base. Since 
`TarArchiveInputStream` and `ZipArchiveInputStream` both extend 
commons-compress `ArchiveInputStream`, the base steps e [...]
    
    ```
    ArchiveReader (abstract)            -- shared readEntries engine + entry 
filtering
      +- TarArchiveReader               -- opens a TarArchiveInputStream 
(explicit .tgz gunzip)
      +- ZipArchiveReader  (new)        -- opens a ZipArchiveInputStream
    ```
    
    - `TarArchiveReader` is reduced to opening a `TarArchiveInputStream` 
(keeping the `.tgz` explicit gunzip, with a defensive close so a gzip-header 
failure can't leak the base stream); behavior is unchanged.
    - New `ZipArchiveReader` opens a `ZipArchiveInputStream` (zip entries are 
individually deflated, so no Hadoop codec layer is applied). It streams local 
file headers sequentially, matching the tar reader's pure-streaming model; a 
few unusual zips (e.g. a stored entry whose size is recorded only in a trailing 
data descriptor) are not streamable this way.
    - `isArchivePath` / `apply` now recognize and dispatch `.zip` (`apply` 
matches the tar and zip extensions explicitly and throws on anything else).
    - The `spark.sql.files.archive.reader.enabled` doc is simplified to 
describe the user-facing behavior without enumerating formats or implementation 
details. No new flag -- zip rides the existing (default-off) gate.
    
    ### Why are the changes needed?
    
    The archive reader already supports CSV, JSON, text, XML, and Avro over 
tar. Zip is one of the most common archive containers for shipped data, and 
extending the same opt-in archive path to zip lets users read and infer schema 
from files packed in a `.zip` without unpacking them first, with the same 
directory-read parity the rest of the series guarantees -- and, because the 
integration is format-agnostic, across every data source already wired up.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. When `spark.sql.files.archive.reader.enabled` is set (default 
`false`), the CSV, JSON, text, XML, and Avro data sources can now read `.zip` 
archives in addition to tar archives -- each archive is read as a single split 
and its entries are streamed through the data source's parser (never unpacked 
to disk), as if the entries were separate files, during both scan and schema 
inference. Previously only `.tar`/`.tar.gz`/`.tgz` were recognized; `.zip` 
files were treated as ordinary (non [...]
    
    ### How was this patch tested?
    
    - `ArchiveReaderSuite` -- added `.zip` cases for `isArchivePath` dispatch 
and `readEntries` (empty/single/multiple entries, directory and dotfile/marker 
skipping, lazy advance, non-closing entry stream), alongside the existing tar 
cases that guard the refactor.
    - New zip format suites mounted on the existing shared scaffolding: 
`CSVHeaderZipArchiveReadSuite`, `CSVHeaderlessZipArchiveReadSuite`, 
`JSONZipArchiveReadSuite`, `XMLZipArchiveReadSuite`, `TextZipArchiveReadSuite`, 
and `AvroZipArchiveReadSuite`. The text read tests were extracted into a 
container-agnostic `TextArchiveReadBase` (with the tar suite slimmed to match 
the CSV/JSON/XML base+container pattern), and 
`ZipArchiveReadBase`/`ZipArchiveTestUtils` are the zip peers of `TarArchiveR 
[...]
    - Re-ran all tar archive suites (including Avro) to confirm the 
shared-engine refactor is behavior-preserving.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code
    
    Closes #56784 from akshatshenoi-db/archive-zip.
    
    Authored-by: akshatshenoi-db <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
    (cherry picked from commit 0dfaffa51078d36e059065219e3c5686f02af4fb)
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../datasources/AvroZipArchiveReadSuite.scala      |  28 +++
 .../org/apache/spark/sql/internal/SQLConf.scala    |   7 +-
 .../sql/execution/datasources/ArchiveReader.scala  | 258 ++++++++++++---------
 .../execution/datasources/ArchiveReaderSuite.scala | 171 +++++++++++++-
 .../datasources/CSVHeaderZipArchiveReadSuite.scala |  28 +++
 .../CSVHeaderlessZipArchiveReadSuite.scala         |  28 +++
 .../datasources/JSONZipArchiveReadSuite.scala      |  28 +++
 ...veReadSuite.scala => TextArchiveReadBase.scala} |  31 ++-
 .../datasources/TextTarArchiveReadSuite.scala      | 159 +------------
 .../datasources/TextZipArchiveReadSuite.scala      |  27 +++
 .../datasources/XMLZipArchiveReadSuite.scala       |  28 +++
 .../execution/datasources/ZipArchiveReadBase.scala |  29 +++
 .../datasources/ZipArchiveTestUtils.scala          |  60 +++++
 13 files changed, 598 insertions(+), 284 deletions(-)

diff --git 
a/connector/avro/src/test/scala/org/apache/spark/sql/execution/datasources/AvroZipArchiveReadSuite.scala
 
b/connector/avro/src/test/scala/org/apache/spark/sql/execution/datasources/AvroZipArchiveReadSuite.scala
new file mode 100644
index 000000000000..24281fb6637f
--- /dev/null
+++ 
b/connector/avro/src/test/scala/org/apache/spark/sql/execution/datasources/AvroZipArchiveReadSuite.scala
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources
+
+/**
+ * Reads of Avro files packed in zip archives (`.zip`): the shared archive 
tests from
+ * [[ArchiveReadSuiteBase]] plus the Avro-specific ones from 
[[AvroArchiveReadBase]], run over zip
+ * containers via [[ZipArchiveReadBase]].
+ */
+class AvroZipArchiveReadSuite
+  extends ArchiveReadSuiteBase
+  with AvroArchiveReadBase
+  with ZipArchiveReadBase
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 2d4d9a75c374..6776f88ed1ef 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -2730,11 +2730,8 @@ object SQLConf {
     .createWithDefaultString("128MB") // parquet.block.size
 
   val ARCHIVE_FORMAT_READER_ENABLED = 
buildConf("spark.sql.files.archive.reader.enabled")
-    .doc("When true, a supported data source can read tar archives (.tar, 
.tar.gz, .tgz): " +
-      "each archive is read as a single split and its entries are streamed 
through that data " +
-      "source's parser (never unpacked to disk), as if the entries were 
separate files, both " +
-      "during scan and schema inference. The CSV, JSON, and text data sources 
support " +
-      "reading archives.")
+    .doc("When true, supported file-based data sources can read archive files, 
reading each " +
+      "archive's entries as if they were separate files, during both scan and 
schema inference.")
     .version("5.0.0")
     .withBindingPolicy(ConfigBindingPolicy.SESSION)
     .booleanConf
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ArchiveReader.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ArchiveReader.scala
index 75f04f8f38c6..1c928fd19da8 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ArchiveReader.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ArchiveReader.scala
@@ -24,7 +24,9 @@ import java.util.zip.GZIPInputStream
 
 import scala.util.control.NonFatal
 
-import org.apache.commons.compress.archivers.tar.{TarArchiveEntry, 
TarArchiveInputStream}
+import org.apache.commons.compress.archivers.{ArchiveEntry, ArchiveInputStream}
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
+import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream
 import org.apache.commons.io.ByteOrderMark
 import org.apache.commons.io.input.{BOMInputStream, CloseShieldInputStream}
 import org.apache.hadoop.conf.Configuration
@@ -48,12 +50,49 @@ import org.apache.spark.util.HadoopFSUtils
  * `parseEntry` that turns one entry stream into rows (or tokens). Formats 
that need random access
  * within a file (e.g. Parquet/ORC footers) cannot use this streaming path.
  *
- * A concrete subclass implements [[readEntries]] for a specific archive 
format. Obtain the reader
- * for a path via `ArchiveReader(path)`, which selects the implementation by 
file extension; new
- * archive formats are added by writing another subclass rather than modifying 
existing ones.
+ * The entry-streaming engine ([[readEntries]]) is shared across archive 
formats -- tar and zip
+ * differ only in how the container stream is opened, and both use 
commons-compress
+ * `ArchiveInputStream`. A concrete subclass implements only 
[[openArchiveStream]]. Obtain the
+ * reader for a path via `ArchiveReader(path)`, which selects the 
implementation by file
+ * extension; new archive formats are added by writing another subclass rather 
than modifying the
+ * shared engine.
  */
 abstract class ArchiveReader(path: Path) {
 
+  /**
+   * Opens the archive at `path` as a commons-compress stream, transparently 
handling its
+   * compression. The shared [[readEntries]] engine steps through entries via 
`getNextEntry`; a
+   * subclass only chooses the container type (e.g. tar vs zip).
+   */
+  protected def openArchiveStream(conf: Configuration): ArchiveInputStream[_ 
<: ArchiveEntry]
+
+  /**
+   * Whether an entry is not a real data file and must be skipped: a 
directory, or a name Spark's
+   * own file listing would filter out. Applying 
[[HadoopFSUtils.shouldFilterOutPathName]] (the
+   * `InMemoryFileIndex` filter) with the same effective 
`ignoredPathSegmentRegex` to every path
+   * component keeps archive reads in parity with reading the same entries as 
loose files,
+   * including when the user supplies a custom `ignoredPathSegmentRegex` 
option: under the default
+   * filter, `.`-prefixed sidecars (macOS `._x`, `.DS_Store`), `_`-prefixed 
markers (`_SUCCESS`,
+   * `_committed_*`), and anything under a `.`/`_`-prefixed directory (e.g. a 
leftover
+   * `_temporary/` from a failed write) are skipped, while data files are 
kept. The per-component
+   * check matters because `InMemoryFileIndex` never recurses into such 
directories, so a
+   * basename-only filter would read `_temporary/part-0.csv` that a loose-file 
scan drops.
+   */
+  private def shouldSkipEntry(entry: ArchiveEntry, ignoredPathSegmentRegex: 
Pattern): Boolean = {
+    if (entry.isDirectory) return true
+    entry.getName.split("/").exists(c =>
+      c.nonEmpty && HadoopFSUtils.shouldFilterOutPathName(c, 
ignoredPathSegmentRegex))
+  }
+
+  /**
+   * Wraps the shared archive stream as a view over exactly the current 
entry's bytes
+   * (`ArchiveInputStream.read` returns -1 at the entry boundary). 
[[CloseShieldInputStream]]
+   * ignores `close()`, so a parser closing its input does not close the 
underlying archive; any
+   * unread remainder of an entry is skipped by `getNextEntry()` when 
advancing.
+   */
+  private def entryStream(archive: ArchiveInputStream[_ <: ArchiveEntry]): 
InputStream =
+    CloseShieldInputStream.wrap(archive)
+
   /**
    * Streams the archive entry by entry, applying `parseEntry` to each 
non-skipped entry's
    * `(name, stream)` and concatenating the results into a single iterator. 
The next entry is opened
@@ -67,25 +106,109 @@ abstract class ArchiveReader(path: Path) {
   def readEntries[T](
       conf: Configuration,
       ignoredPathSegmentRegex: Pattern = 
HadoopFSUtils.defaultIgnoredPathSegmentRegexPattern)(
-      parseEntry: (String, InputStream) => Iterator[T]): Iterator[T]
+      parseEntry: (String, InputStream) => Iterator[T]): Iterator[T] = {
+    val archive = openArchiveStream(conf)
+    var closed = false
+
+    def cleanup(): Unit = {
+      if (!closed) {
+        closed = true
+        try archive.close() catch { case NonFatal(_) => }
+      }
+    }
+
+    Option(TaskContext.get()).foreach(_.addTaskCompletionListener[Unit](_ => 
cleanup()))
+
+    val entries = new Iterator[T] with Closeable {
+      private var currentIter: Iterator[T] = Iterator.empty
+      private var done = false
+
+      // Move to the next entry whose iterator has elements (releasing each 
exhausted entry's
+      // reader and skipping any unread bytes), or mark the stream done once 
entries run out.
+      // Advancing here -- driven by `hasNext` -- rather than eagerly after 
producing a row in
+      // `next` is essential for parsers that reuse a single mutable row and 
look ahead on
+      // `hasNext`: probing the current entry right after returning a row 
would overwrite that row's
+      // contents before the caller has copied it.
+      private def advance(): Unit = {
+        while (!done && !currentIter.hasNext) {
+          currentIter match {
+            case c: Closeable => try c.close() catch { case NonFatal(_) => }
+            case _ =>
+          }
+          var entry = archive.getNextEntry
+          while (entry != null && shouldSkipEntry(entry, 
ignoredPathSegmentRegex)) {
+            entry = archive.getNextEntry
+          }
+          if (entry == null) {
+            done = true
+            cleanup()
+          } else {
+            currentIter = parseEntry(entry.getName, entryStream(archive))
+          }
+        }
+      }
+
+      override def hasNext: Boolean = {
+        advance()
+        !done && currentIter.hasNext
+      }
+
+      override def next(): T = {
+        if (!hasNext) throw new NoSuchElementException
+        currentIter.next()
+      }
+
+      override def close(): Unit = {
+        done = true
+        currentIter = Iterator.empty
+        cleanup()
+      }
+    }
+
+    // Open the first entry eagerly so the construction cost (and any failure) 
surfaces here rather
+    // than at the first `hasNext`. A corrupt archive throws before the caller 
ever holds the
+    // iterator, leaving it nothing to close: executors release the stream 
through the task-
+    // completion listener, but driver-side callers (e.g. Avro's header-only 
schema inference) have
+    // no task, so close it here before propagating.
+    try {
+      entries.hasNext
+    } catch {
+      case NonFatal(e) =>
+        cleanup()
+        throw e
+    }
+    entries
+  }
 }
 
 object ArchiveReader {
 
   /**
    * Whether `path` names an archive this reader can stream. Dispatched purely 
on the file
-   * extension -- `.tar`, `.tar.gz`, or `.tgz` -- since the bytes are not 
inspected here.
+   * extension -- `.tar`, `.tar.gz`, `.tgz`, or `.zip` -- since the bytes are 
not inspected here.
    */
   def isArchivePath(path: Path): Boolean = {
     val name = path.getName.toLowerCase(Locale.ROOT)
-    name.endsWith(".tar") || name.endsWith(".tar.gz") || name.endsWith(".tgz")
+    name.endsWith(".tar") || name.endsWith(".tar.gz") || name.endsWith(".tgz") 
||
+      name.endsWith(".zip")
   }
 
   /**
    * Returns the [[ArchiveReader]] implementation for `path`, selected by its 
file extension. Only
    * paths for which [[isArchivePath]] is true are supported; new archive 
formats add a case here.
    */
-  def apply(path: Path): ArchiveReader = new TarArchiveReader(path)
+  def apply(path: Path): ArchiveReader = {
+    val name = path.getName.toLowerCase(Locale.ROOT)
+    name match {
+      case n if n.endsWith(".tar") || n.endsWith(".tar.gz") || 
n.endsWith(".tgz") =>
+        new TarArchiveReader(path)
+      case n if n.endsWith(".zip") =>
+        new ZipArchiveReader(path)
+      case _ =>
+        throw new IllegalArgumentException(
+          s"$path is not a supported archive (expected .tar, .tar.gz, .tgz, or 
.zip)")
+    }
+  }
 
   /**
    * Splits one already-decompressed archive entry's bytes into lines. The 
reusable, format-agnostic
@@ -149,26 +272,8 @@ class TarArchiveReader(path: Path) extends 
ArchiveReader(path) {
   private def needsExplicitGunzip: Boolean =
     path.getName.toLowerCase(Locale.ROOT).endsWith(".tgz")
 
-  /**
-   * Whether an entry is not a real data file and must be skipped: a 
directory, or a name Spark's
-   * own file listing would filter out. Applying 
[[HadoopFSUtils.shouldFilterOutPathName]] (the
-   * `InMemoryFileIndex` filter) with the same effective 
`ignoredPathSegmentRegex` to every path
-   * component keeps archive reads in parity with reading the same entries as 
loose files,
-   * including when the user supplies a custom `ignoredPathSegmentRegex` 
option: under the default
-   * filter, `.`-prefixed sidecars (macOS `._x`, `.DS_Store`), `_`-prefixed 
markers (`_SUCCESS`,
-   * `_committed_*`), and anything under a `.`/`_`-prefixed directory (e.g. a 
leftover
-   * `_temporary/` from a failed write) are skipped, while data files are 
kept. The per-component
-   * check matters because `InMemoryFileIndex` never recurses into such 
directories, so a
-   * basename-only filter would read `_temporary/part-0.csv` that a loose-file 
scan drops.
-   */
-  private def shouldSkipEntry(entry: TarArchiveEntry, ignoredPathSegmentRegex: 
Pattern): Boolean = {
-    if (entry.isDirectory) return true
-    entry.getName.split("/").exists(c =>
-      c.nonEmpty && HadoopFSUtils.shouldFilterOutPathName(c, 
ignoredPathSegmentRegex))
-  }
-
-  /** Opens the archive as a tar stream, transparently decompressing `.tar.gz` 
/ `.tgz`. */
-  private def openTarStream(conf: Configuration): TarArchiveInputStream = {
+  override protected def openArchiveStream(
+      conf: Configuration): ArchiveInputStream[_ <: ArchiveEntry] = {
     val base = CodecStreams.createInputStreamWithCloseResource(conf, path)
     try {
       // GZIPInputStream reads the gzip header in its constructor, so a 
corrupt archive can throw
@@ -181,90 +286,19 @@ class TarArchiveReader(path: Path) extends 
ArchiveReader(path) {
         throw e
     }
   }
+}
 
-  /**
-   * Wraps the shared tar stream as a view over exactly the current entry's 
bytes
-   * (`TarArchiveInputStream.read` returns -1 at the entry boundary). 
[[CloseShieldInputStream]]
-   * ignores `close()`, so a parser closing its input does not close the 
underlying archive; any
-   * unread remainder of an entry is skipped by `getNextEntry()` when 
advancing.
-   */
-  private def entryStream(tar: TarArchiveInputStream): InputStream =
-    CloseShieldInputStream.wrap(tar)
-
-  override def readEntries[T](
-      conf: Configuration,
-      ignoredPathSegmentRegex: Pattern)(
-      parseEntry: (String, InputStream) => Iterator[T]): Iterator[T] = {
-    val tar = openTarStream(conf)
-    var closed = false
-
-    def cleanup(): Unit = {
-      if (!closed) {
-        closed = true
-        try tar.close() catch { case NonFatal(_) => }
-      }
-    }
-
-    Option(TaskContext.get()).foreach(_.addTaskCompletionListener[Unit](_ => 
cleanup()))
-
-    val entries = new Iterator[T] with Closeable {
-      private var currentIter: Iterator[T] = Iterator.empty
-      private var done = false
-
-      // Move to the next entry whose iterator has elements (releasing each 
exhausted entry's
-      // reader and skipping any unread bytes), or mark the stream done once 
entries run out.
-      // Advancing here -- driven by `hasNext` -- rather than eagerly after 
producing a row in
-      // `next` is essential for parsers that reuse a single mutable row and 
look ahead on
-      // `hasNext`: probing the current entry right after returning a row 
would overwrite that row's
-      // contents before the caller has copied it.
-      private def advance(): Unit = {
-        while (!done && !currentIter.hasNext) {
-          currentIter match {
-            case c: Closeable => try c.close() catch { case NonFatal(_) => }
-            case _ =>
-          }
-          var entry = tar.getNextEntry
-          while (entry != null && shouldSkipEntry(entry, 
ignoredPathSegmentRegex)) {
-            entry = tar.getNextEntry
-          }
-          if (entry == null) {
-            done = true
-            cleanup()
-          } else {
-            currentIter = parseEntry(entry.getName, entryStream(tar))
-          }
-        }
-      }
-
-      override def hasNext: Boolean = {
-        advance()
-        !done && currentIter.hasNext
-      }
-
-      override def next(): T = {
-        if (!hasNext) throw new NoSuchElementException
-        currentIter.next()
-      }
-
-      override def close(): Unit = {
-        done = true
-        currentIter = Iterator.empty
-        cleanup()
-      }
-    }
+/**
+ * [[ArchiveReader]] for zip archives (`.zip`). Each entry is individually 
compressed inside the
+ * container (the container itself is not gzip-wrapped), so 
`ZipArchiveInputStream` decompresses
+ * entries as they are streamed and no Hadoop codec layer is applied. The 
stream reads local file
+ * headers sequentially rather than the central directory, matching the tar 
reader's pure-streaming
+ * model: a few unusual zips (e.g. a stored entry whose size is recorded only 
in a trailing data
+ * descriptor) are not streamable this way.
+ */
+class ZipArchiveReader(path: Path) extends ArchiveReader(path) {
 
-    // Open the first entry eagerly so the construction cost (and any failure) 
surfaces here rather
-    // than at the first `hasNext`. A corrupt archive throws before the caller 
ever holds the
-    // iterator, leaving it nothing to close: executors release the stream 
through the task-
-    // completion listener, but driver-side callers (e.g. Avro's header-only 
schema inference) have
-    // no task, so close it here before propagating.
-    try {
-      entries.hasNext
-    } catch {
-      case NonFatal(e) =>
-        cleanup()
-        throw e
-    }
-    entries
-  }
+  override protected def openArchiveStream(
+      conf: Configuration): ArchiveInputStream[_ <: ArchiveEntry] =
+    new 
ZipArchiveInputStream(CodecStreams.createInputStreamWithCloseResource(conf, 
path))
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReaderSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReaderSuite.scala
index 2cbd1ddd9fd3..e49800ec0640 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReaderSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReaderSuite.scala
@@ -26,6 +26,7 @@ import java.util.zip.GZIPOutputStream
 import scala.collection.mutable.ArrayBuffer
 
 import org.apache.commons.compress.archivers.tar.{TarArchiveEntry, 
TarArchiveOutputStream}
+import org.apache.commons.compress.archivers.zip.{ZipArchiveEntry, 
ZipArchiveOutputStream}
 import org.apache.hadoop.conf.Configuration
 import org.apache.hadoop.fs.Path
 
@@ -63,6 +64,64 @@ class ArchiveReaderSuite extends SparkFunSuite {
     } finally out.close()
   }
 
+  /** Write a zip archive, used to verify the `.zip` archive path. */
+  private def writeZip(file: File, entries: Seq[Entry]): Unit = {
+    val out = new ZipArchiveOutputStream(new FileOutputStream(file))
+    try {
+      entries.foreach { e =>
+        // A trailing slash marks a directory entry.
+        val rawName = if (e.isDir && !e.name.endsWith("/")) e.name + "/" else 
e.name
+        val zipEntry = new ZipArchiveEntry(rawName)
+        if (!e.isDir) zipEntry.setSize(e.data.length.toLong)
+        out.putArchiveEntry(zipEntry)
+        if (!e.isDir) out.write(e.data)
+        out.closeArchiveEntry()
+      }
+      out.finish()
+    } finally out.close()
+  }
+
+  /**
+   * Writes a zip with one STORED (uncompressed) entry that uses a data 
descriptor: general-purpose
+   * bit 3 is set and the local header's crc/size fields are zeroed, so the 
real values live only in
+   * the trailing data descriptor. `ZipArchiveInputStream` cannot stream such 
an entry -- it has no
+   * size to bound the read -- so `read` throws rather than yielding truncated 
bytes. This is the
+   * non-streamable case `ZipArchiveReader` documents; 
`ZipArchiveOutputStream` cannot produce it
+   * (it rejects an unsized STORED entry, or rewrites the header when the sink 
is seekable), so the
+   * bytes are assembled by hand.
+   */
+  private def writeStoredEntryWithDataDescriptor(file: File, name: String, 
body: String): Unit = {
+    val nameBytes = name.getBytes(StandardCharsets.UTF_8)
+    val data = body.getBytes(StandardCharsets.UTF_8)
+    val crc = { val c = new java.util.zip.CRC32(); c.update(data); c.getValue }
+    val out = new ByteArrayOutputStream()
+    def u16(v: Int): Unit = { out.write(v & 0xFF); out.write((v >>> 8) & 0xFF) 
}
+    def u32(v: Long): Unit = {
+      out.write((v & 0xFF).toInt); out.write(((v >>> 8) & 0xFF).toInt)
+      out.write(((v >>> 16) & 0xFF).toInt); out.write(((v >>> 24) & 
0xFF).toInt)
+    }
+    // Local file header: GP bit 3 set (data descriptor), STORED method, sizes 
zeroed here.
+    val localHeaderOffset = out.size()
+    u32(0x04034b50L); u16(10); u16(0x0008); u16(0); u16(0); u16(0)
+    u32(0); u32(0); u32(0)
+    u16(nameBytes.length); u16(0)
+    out.write(nameBytes); out.write(data)
+    // Data descriptor (with optional signature): the real crc and sizes.
+    u32(0x08074b50L); u32(crc); u32(data.length.toLong); 
u32(data.length.toLong)
+    // Central directory.
+    val cdOffset = out.size()
+    u32(0x02014b50L); u16(20); u16(10); u16(0x0008); u16(0); u16(0); u16(0)
+    u32(crc); u32(data.length.toLong); u32(data.length.toLong)
+    u16(nameBytes.length); u16(0); u16(0); u16(0); u16(0); u32(0); 
u32(localHeaderOffset.toLong)
+    out.write(nameBytes)
+    val cdSize = out.size() - cdOffset
+    // End of central directory.
+    u32(0x06054b50L); u16(0); u16(0); u16(1); u16(1)
+    u32(cdSize.toLong); u32(cdOffset.toLong); u16(0)
+    val fos = new FileOutputStream(file)
+    try fos.write(out.toByteArray) finally fos.close()
+  }
+
   private def textEntry(name: String, body: String): Entry =
     Entry(name, body.getBytes(StandardCharsets.UTF_8))
 
@@ -89,15 +148,16 @@ class ArchiveReaderSuite extends SparkFunSuite {
     Seq(
       "foo.tar", "FOO.TAR", "/a/b/c/x.tar", "weird.TaR",
       "foo.tar.gz", "FOO.TAR.GZ", "mixed.Tar.Gz", "/a/b/c/x.tar.gz",
-      "foo.tgz", "FOO.TGZ", "/a/b/c/x.tgz"
+      "foo.tgz", "FOO.TGZ", "/a/b/c/x.tgz",
+      "data.zip", "FOO.ZIP", "weird.ZiP", "/a/b/c/x.zip"
     ).foreach { p =>
       assert(ArchiveReader.isArchivePath(new Path(p)), s"expected archive 
match for $p")
     }
   }
 
   test("isArchivePath: negative cases") {
-    Seq("foo.csv", "foo.gz", "foo", "dir/", "foo.tarball", "data.zip",
-        "foo.tar.bz2", "foo.targz").foreach { p =>
+    Seq("foo.csv", "foo.gz", "foo", "dir/", "foo.tarball",
+        "foo.tar.bz2", "foo.targz", "foo.zipx", "foo.gzip").foreach { p =>
       assert(!ArchiveReader.isArchivePath(new Path(p)), s"expected non-match 
for $p")
     }
   }
@@ -274,4 +334,109 @@ class ArchiveReaderSuite extends SparkFunSuite {
       }
     }
   }
+
+  // ----- zip ----------------------------------------------------------------
+  // The streaming engine is shared with tar (only stream-opening differs), so 
these cases focus on
+  // the `.zip` dispatch and the `ZipArchiveInputStream` container behaving 
like the tar path.
+
+  test("readEntries: empty zip yields empty iterator") {
+    withTempDir { dir =>
+      val zip = new File(dir, "empty.zip")
+      writeZip(zip, Seq.empty)
+      assert(collect(zip).isEmpty)
+    }
+  }
+
+  test("readEntries: zip single entry exposes its name and bytes") {
+    withTempDir { dir =>
+      val zip = new File(dir, "single.zip")
+      writeZip(zip, Seq(textEntry("only.csv", "hello\n")))
+      assert(collect(zip) == Seq("only.csv" -> "hello\n"))
+    }
+  }
+
+  test("readEntries: zip multiple entries chained in archive order") {
+    withTempDir { dir =>
+      val zip = new File(dir, "multi.zip")
+      writeZip(zip, Seq(textEntry("a.csv", "a"), textEntry("b.csv", "b"), 
textEntry("c.csv", "c")))
+      assert(collect(zip) == Seq("a.csv" -> "a", "b.csv" -> "b", "c.csv" -> 
"c"))
+    }
+  }
+
+  test("readEntries: zip directory entries are skipped") {
+    withTempDir { dir =>
+      val zip = new File(dir, "dirs.zip")
+      writeZip(zip, Seq(
+        Entry("subdir", Array.emptyByteArray, isDir = true),
+        textEntry("subdir/data.csv", "x")))
+      assert(collect(zip) == Seq("subdir/data.csv" -> "x"))
+    }
+  }
+
+  test("readEntries: zip dotfile, underscore-marker, and prefixed-dir entries 
are skipped") {
+    withTempDir { dir =>
+      val zip = new File(dir, "skipped.zip")
+      writeZip(zip, Seq(
+        textEntry("._real.csv", "junk"),           // macOS AppleDouble sidecar
+        textEntry(".hidden", "ignored"),           // bare dotfile
+        textEntry("_SUCCESS", "marker"),           // _-prefixed marker 
(InMemoryFileIndex skips it)
+        textEntry("_temporary/part-0.csv", "tmp"), // entry under a _-prefixed 
dir (skipped whole)
+        textEntry("real.csv", "kept"),
+        textEntry("nested/._sidecar", "junk2")))   // dotfile in a subdir
+      assert(collect(zip) == Seq("real.csv" -> "kept"))
+    }
+  }
+
+  test("readEntries: zip advances lazily, one entry at a time") {
+    withTempDir { dir =>
+      val zip = new File(dir, "lazy.zip")
+      writeZip(zip, Seq(textEntry("a.csv", "a"), textEntry("b.csv", "b"), 
textEntry("c.csv", "c")))
+
+      val opened = ArrayBuffer[String]()
+      val it = ArchiveReader(new Path(zip.toURI)).readEntries(new 
Configuration()) { (name, _) =>
+        opened += name
+        Iterator.single(name)
+      }
+      // Construction opens only the first entry; advancing past each boundary 
opens the next.
+      assert(opened.toList == List("a.csv"))
+      assert(it.hasNext)
+      assert(it.next() == "a.csv")
+      assert(opened.toList == List("a.csv"))
+      assert(it.next() == "b.csv")
+      assert(opened.toList == List("a.csv", "b.csv"))
+      assert(it.next() == "c.csv")
+      assert(opened.toList == List("a.csv", "b.csv", "c.csv"))
+      assert(!it.hasNext)
+      assert(opened.size == 3)
+    }
+  }
+
+  test("readEntries: a zip parseEntry that closes its stream still advances to 
the next entry") {
+    withTempDir { dir =>
+      val zip = new File(dir, "close.zip")
+      writeZip(zip, Seq(textEntry("a.csv", "a"), textEntry("b.csv", "b")))
+
+      val seen = ArrayBuffer[String]()
+      val it = ArchiveReader(new Path(zip.toURI)).readEntries(new 
Configuration()) { (name, in) =>
+        val body = new String(readAll(in), StandardCharsets.UTF_8)
+        in.close() // must NOT close the underlying archive
+        seen += body
+        Iterator.single(name)
+      }
+      assert(it.toList == List("a.csv", "b.csv"))
+      assert(seen.toList == List("a", "b"))
+    }
+  }
+
+  test("readEntries: a non-streamable zip entry fails loudly rather than 
yielding garbled bytes") {
+    withTempDir { dir =>
+      val zip = new File(dir, "stored-dd.zip")
+      writeStoredEntryWithDataDescriptor(zip, "a.csv", "hello")
+      // A stored entry sized only by a trailing data descriptor is the 
documented non-streamable
+      // case: ZipArchiveInputStream throws on read instead of returning 
truncated/garbled bytes.
+      val ex = intercept[java.io.IOException](collect(zip))
+      assert(ex.getMessage != null && ex.getMessage.contains("data 
descriptor"),
+        s"expected a clear unsupported-feature error, got $ex")
+    }
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVHeaderZipArchiveReadSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVHeaderZipArchiveReadSuite.scala
new file mode 100644
index 000000000000..eb3bf1d78596
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVHeaderZipArchiveReadSuite.scala
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources
+
+/**
+ * Reads of header-carrying CSV files packed in zip archives (`.zip`): the 
shared archive tests from
+ * [[ArchiveReadSuiteBase]] plus the header-mode CSV tests from 
[[CSVHeaderArchiveReadBase]], run
+ * over zip containers via [[ZipArchiveReadBase]].
+ */
+class CSVHeaderZipArchiveReadSuite
+  extends ArchiveReadSuiteBase
+  with CSVHeaderArchiveReadBase
+  with ZipArchiveReadBase
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVHeaderlessZipArchiveReadSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVHeaderlessZipArchiveReadSuite.scala
new file mode 100644
index 000000000000..b4bddb603725
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVHeaderlessZipArchiveReadSuite.scala
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources
+
+/**
+ * Reads of headerless CSV files packed in zip archives (`.zip`): the shared 
archive tests from
+ * [[ArchiveReadSuiteBase]] plus the headerless CSV tests from 
[[CSVHeaderlessArchiveReadBase]], run
+ * over zip containers via [[ZipArchiveReadBase]].
+ */
+class CSVHeaderlessZipArchiveReadSuite
+  extends ArchiveReadSuiteBase
+  with CSVHeaderlessArchiveReadBase
+  with ZipArchiveReadBase
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONZipArchiveReadSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONZipArchiveReadSuite.scala
new file mode 100644
index 000000000000..76cee32238b3
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONZipArchiveReadSuite.scala
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources
+
+/**
+ * Reads of JSON files packed in zip archives (`.zip`): the shared archive 
tests from
+ * [[ArchiveReadSuiteBase]] plus the JSON-specific ones from 
[[JSONArchiveReadBase]], run over zip
+ * containers via [[ZipArchiveReadBase]].
+ */
+class JSONZipArchiveReadSuite
+  extends ArchiveReadSuiteBase
+  with JSONArchiveReadBase
+  with ZipArchiveReadBase
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextTarArchiveReadSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextArchiveReadBase.scala
similarity index 81%
copy from 
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextTarArchiveReadSuite.scala
copy to 
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextArchiveReadBase.scala
index 0163d03a168a..e2506afa6b34 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextTarArchiveReadSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextArchiveReadBase.scala
@@ -28,16 +28,29 @@ import org.apache.spark.sql.test.SharedSparkSession
 import org.apache.spark.util.Utils
 
 /**
- * Reads of text files packed in tar archives (`.tar`/`.tar.gz`/`.tgz`), 
streamed through the
- * [[ArchiveReader]] path. Entries are streamed (never unpacked to disk), and 
the central contract
- * verified throughout is parity with reading the same files from a directory.
+ * Reads of text files packed in archives, streamed through the 
[[ArchiveReader]] path. Entries are
+ * streamed (never unpacked to disk), and the central contract verified 
throughout is parity with
+ * reading the same files from a directory.
  *
- * Unlike CSV/JSON this does not reuse [[ArchiveReadSuiteBase]]: the text data 
source has a single
+ * Unlike CSV/JSON this does not extend [[ArchiveReadSuiteBase]]: the text 
data source has a single
  * fixed `value` column (one row per line, or per entry with `wholetext`) and 
no schema inference,
- * so the structured, two-column tests there do not apply. The tar 
container-writing helpers come
- * from [[TarArchiveTestUtils]] (shared with [[TarArchiveReadBase]]).
+ * so the structured, two-column tests there do not apply. The 
container-writing hooks below are
+ * supplied by a container trait the concrete suite mixes in 
([[TarArchiveTestUtils]] /
+ * [[ZipArchiveTestUtils]]); `corruptArchiveExtension` is supplied by the 
concrete suite.
  */
-class TextTarArchiveReadSuite extends QueryTest with SharedSparkSession with 
TarArchiveTestUtils {
+trait TextArchiveReadBase extends QueryTest with SharedSparkSession {
+
+  /** Archive extensions to exercise; the head is the default. Supplied by the 
container trait. */
+  protected def archiveExtensions: Seq[String]
+
+  /** Writes `entries` (name -> bytes) into the archive at `dest`. From the 
container trait. */
+  protected def writeArchive(dest: File, entries: Seq[(String, Array[Byte])]): 
Unit
+
+  /** Writes bytes that are not a readable archive at `dest`. From the 
container trait. */
+  protected def writeCorruptArchive(dest: File): Unit
+
+  /** Extension of the archive [[writeCorruptArchive]] produces (corruption is 
format-specific). */
+  protected def corruptArchiveExtension: String
 
   override def sparkConf: SparkConf =
     super.sparkConf.set(SQLConf.ARCHIVE_FORMAT_READER_ENABLED.key, "true")
@@ -54,7 +67,7 @@ class TextTarArchiveReadSuite extends QueryTest with 
SharedSparkSession with Tar
   private def read(path: String, options: Map[String, String] = Map.empty): 
DataFrame =
     spark.read.options(options).text(path)
 
-  test("read a tar archive of multiple text entries matches the union of the 
lines") {
+  test("read an archive of multiple text entries matches the union of the 
lines") {
     archiveExtensions.foreach { ext =>
       withArchiveFile(ext) { archive =>
         writeArchive(archive, Seq(
@@ -144,7 +157,7 @@ class TextTarArchiveReadSuite extends QueryTest with 
SharedSparkSession with Tar
 
   Seq(true, false).foreach { ignoreCorrupt =>
     test(s"ignoreCorruptFiles=$ignoreCorrupt controls whether a corrupt 
archive is skipped") {
-      withArchiveFile("tar.gz") { archive =>
+      withArchiveFile(corruptArchiveExtension) { archive =>
         writeCorruptArchive(archive)
         withSQLConf(SQLConf.IGNORE_CORRUPT_FILES.key -> 
ignoreCorrupt.toString) {
           if (ignoreCorrupt) {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextTarArchiveReadSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextTarArchiveReadSuite.scala
index 0163d03a168a..1764bfdefcfb 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextTarArchiveReadSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextTarArchiveReadSuite.scala
@@ -17,162 +17,11 @@
 
 package org.apache.spark.sql.execution.datasources
 
-import java.io.File
-import java.nio.charset.StandardCharsets
-import java.nio.file.Files
-
-import org.apache.spark.{SparkConf, SparkException}
-import org.apache.spark.sql.{DataFrame, QueryTest, Row}
-import org.apache.spark.sql.internal.SQLConf
-import org.apache.spark.sql.test.SharedSparkSession
-import org.apache.spark.util.Utils
-
 /**
- * Reads of text files packed in tar archives (`.tar`/`.tar.gz`/`.tgz`), 
streamed through the
- * [[ArchiveReader]] path. Entries are streamed (never unpacked to disk), and 
the central contract
- * verified throughout is parity with reading the same files from a directory.
- *
- * Unlike CSV/JSON this does not reuse [[ArchiveReadSuiteBase]]: the text data 
source has a single
- * fixed `value` column (one row per line, or per entry with `wholetext`) and 
no schema inference,
- * so the structured, two-column tests there do not apply. The tar 
container-writing helpers come
- * from [[TarArchiveTestUtils]] (shared with [[TarArchiveReadBase]]).
+ * Reads of text files packed in tar archives (`.tar`/`.tar.gz`/`.tgz`): the 
shared text archive
+ * tests from [[TextArchiveReadBase]], run over tar containers via 
[[TarArchiveTestUtils]].
  */
-class TextTarArchiveReadSuite extends QueryTest with SharedSparkSession with 
TarArchiveTestUtils {
-
-  override def sparkConf: SparkConf =
-    super.sparkConf.set(SQLConf.ARCHIVE_FORMAT_READER_ENABLED.key, "true")
-
-  private def textBytes(s: String): Array[Byte] = 
s.getBytes(StandardCharsets.UTF_8)
-
-  /** Provides an archive-extensioned path inside a fresh temp dir to `f`. */
-  private def withArchiveFile(
-      extension: String = archiveExtensions.head)(f: File => Unit): Unit = {
-    val dir = Utils.createTempDir(namePrefix = "archive-test")
-    try f(new File(dir, s"archive.$extension")) finally 
Utils.deleteRecursively(dir)
-  }
-
-  private def read(path: String, options: Map[String, String] = Map.empty): 
DataFrame =
-    spark.read.options(options).text(path)
-
-  test("read a tar archive of multiple text entries matches the union of the 
lines") {
-    archiveExtensions.foreach { ext =>
-      withArchiveFile(ext) { archive =>
-        writeArchive(archive, Seq(
-          "a.txt" -> textBytes("line1\nline2\n"),
-          "b.txt" -> textBytes("line3\n"),
-          "c.txt" -> textBytes("line4\nline5\n")))
-        checkAnswer(
-          read(archive.getCanonicalPath),
-          Seq("line1", "line2", "line3", "line4", "line5").map(Row(_)))
-      }
-    }
-  }
-
-  test("archive entries read like a directory of the same files") {
-    val entries = Seq("a.txt" -> textBytes("a1\na2\n"), "b.txt" -> 
textBytes("b1\n"))
-    withArchiveFile() { archive =>
-      writeArchive(archive, entries)
-      val fromArchive = read(archive.getCanonicalPath)
-      withTempDir { dir =>
-        entries.foreach { case (n, b) => Files.write(new File(dir, n).toPath, 
b) }
-        checkAnswer(fromArchive, read(dir.getCanonicalPath).collect().toSeq)
-      }
-    }
-  }
-
-  test("an empty archive yields no rows") {
-    withArchiveFile() { archive =>
-      writeArchive(archive, Seq.empty)
-      checkAnswer(read(archive.getCanonicalPath), Seq.empty[Row])
-    }
-  }
-
-  test("an archive and loose text files in the same directory are all read") {
-    withTempDir { dir =>
-      val ext = archiveExtensions.head
-      writeArchive(
-        new File(dir, s"data.$ext"),
-        Seq("a.txt" -> textBytes("in-archive-1\nin-archive-2\n")))
-      Files.write(new File(dir, "loose.txt").toPath, textBytes("loose-1\n"))
-      checkAnswer(
-        read(dir.getCanonicalPath),
-        Seq("in-archive-1", "in-archive-2", "loose-1").map(Row(_)))
-    }
-  }
-
-  test("wholetext reads each entry as a single row") {
-    withArchiveFile() { archive =>
-      writeArchive(archive, Seq(
-        "a.txt" -> textBytes("l1\nl2"),
-        "b.txt" -> textBytes("only")))
-      checkAnswer(
-        read(archive.getCanonicalPath, Map("wholetext" -> "true")),
-        Seq(Row("l1\nl2"), Row("only")))
-    }
-  }
-
-  test("a custom line separator splits entries into rows") {
-    withArchiveFile() { archive =>
-      writeArchive(archive, Seq("a.txt" -> textBytes("x;y;z")))
-      checkAnswer(
-        read(archive.getCanonicalPath, Map("lineSep" -> ";")),
-        Seq(Row("x"), Row("y"), Row("z")))
-    }
-  }
-
-  test("count over an archive reads the right number of rows with an empty 
required schema") {
-    withArchiveFile() { archive =>
-      writeArchive(archive, Seq(
-        "a.txt" -> textBytes("1\n2\n3\n"),
-        "b.txt" -> textBytes("4\n")))
-      assert(read(archive.getCanonicalPath).count() == 4L)
-    }
-  }
-
-  test("an archive always yields a single partition regardless of size") {
-    withArchiveFile() { archive =>
-      val big = (1 to 1000).map(i => s"value-$i").mkString("\n")
-      writeArchive(archive, (0 until 4).map(i => s"part-$i.txt" -> 
textBytes(big + "\n")))
-      withSQLConf(SQLConf.FILES_MAX_PARTITION_BYTES.key -> "1024") {
-        val readDf = read(archive.getCanonicalPath)
-        assert(readDf.rdd.getNumPartitions == 1,
-          s"archive should be a single partition; got 
${readDf.rdd.getNumPartitions}")
-        assert(readDf.count() == 4000L)
-      }
-    }
-  }
-
-  Seq(true, false).foreach { ignoreCorrupt =>
-    test(s"ignoreCorruptFiles=$ignoreCorrupt controls whether a corrupt 
archive is skipped") {
-      withArchiveFile("tar.gz") { archive =>
-        writeCorruptArchive(archive)
-        withSQLConf(SQLConf.IGNORE_CORRUPT_FILES.key -> 
ignoreCorrupt.toString) {
-          if (ignoreCorrupt) {
-            checkAnswer(read(archive.getCanonicalPath), Seq.empty[Row])
-          } else {
-            intercept[SparkException](read(archive.getCanonicalPath).collect())
-          }
-        }
-      }
-    }
-  }
+class TextTarArchiveReadSuite extends TextArchiveReadBase with 
TarArchiveTestUtils {
 
-  Seq(true, false).foreach { ignoreMissing =>
-    test(s"ignoreMissingFiles=$ignoreMissing controls whether a missing 
archive is skipped") {
-      withArchiveFile() { archive =>
-        writeArchive(archive, Seq("a.txt" -> textBytes("line1\nline2\n")))
-        withSQLConf(SQLConf.IGNORE_MISSING_FILES.key -> 
ignoreMissing.toString) {
-          // The archive is listed when the DataFrame is built, then deleted 
before the scan opens
-          // it, so the reader hits a missing file -- handled by 
`FileScanRDD`, like any file.
-          val df = read(archive.getCanonicalPath)
-          assert(archive.delete(), s"failed to delete $archive")
-          if (ignoreMissing) {
-            checkAnswer(df, Seq.empty[Row])
-          } else {
-            intercept[SparkException](df.collect())
-          }
-        }
-      }
-    }
-  }
+  override protected def corruptArchiveExtension: String = "tar.gz"
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextZipArchiveReadSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextZipArchiveReadSuite.scala
new file mode 100644
index 000000000000..a22951e8d5df
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TextZipArchiveReadSuite.scala
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources
+
+/**
+ * Reads of text files packed in zip archives (`.zip`): the shared text 
archive tests from
+ * [[TextArchiveReadBase]], run over zip containers via 
[[ZipArchiveTestUtils]].
+ */
+class TextZipArchiveReadSuite extends TextArchiveReadBase with 
ZipArchiveTestUtils {
+
+  override protected def corruptArchiveExtension: String = "zip"
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/XMLZipArchiveReadSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/XMLZipArchiveReadSuite.scala
new file mode 100644
index 000000000000..9721561b756f
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/XMLZipArchiveReadSuite.scala
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources
+
+/**
+ * Reads of XML files packed in zip archives (`.zip`): the shared archive 
tests from
+ * [[ArchiveReadSuiteBase]] plus the XML-specific ones from 
[[XMLArchiveReadBase]], run over zip
+ * containers via [[ZipArchiveReadBase]].
+ */
+class XMLZipArchiveReadSuite
+  extends ArchiveReadSuiteBase
+  with XMLArchiveReadBase
+  with ZipArchiveReadBase
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveReadBase.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveReadBase.scala
new file mode 100644
index 000000000000..516468a30584
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveReadBase.scala
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources
+
+/**
+ * Binds [[ArchiveReadSuiteBase]]'s archive-format hooks to zip containers 
(`.zip`). The
+ * container-writing helpers live in [[ZipArchiveTestUtils]] (shared with 
standalone suites that
+ * cannot extend `ArchiveReadSuiteBase`). Reusable across file formats -- a
+ * `<format>ZipArchiveReadSuite` mixes this in alongside the file-format trait.
+ */
+trait ZipArchiveReadBase extends ArchiveReadSuiteBase with ZipArchiveTestUtils 
{
+
+  override protected def corruptArchiveExtension: String = "zip"
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveTestUtils.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveTestUtils.scala
new file mode 100644
index 000000000000..cf9e42262a7b
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveTestUtils.scala
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources
+
+import java.io.{File, FileOutputStream}
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+
+import org.apache.commons.compress.archivers.zip.{ZipArchiveEntry, 
ZipArchiveOutputStream}
+
+/**
+ * Zip container-writing helpers (`.zip`), independent of any read-test 
harness -- the zip peer of
+ * [[TarArchiveTestUtils]]. [[ZipArchiveReadBase]] mixes this into 
[[ArchiveReadSuiteBase]] for the
+ * format-agnostic suites, and standalone suites that cannot extend 
`ArchiveReadSuiteBase` (e.g.
+ * `TextZipArchiveReadSuite`) mix it in directly, so the container logic lives 
in one place.
+ */
+trait ZipArchiveTestUtils {
+
+  /** Zip extensions to exercise; the head is the default. */
+  protected def archiveExtensions: Seq[String] = Seq("zip")
+
+  /** Writes `entries` (name -> bytes) into the zip archive at `dest`. */
+  protected def writeArchive(dest: File, entries: Seq[(String, Array[Byte])]): 
Unit = {
+    val out = new ZipArchiveOutputStream(new FileOutputStream(dest))
+    try {
+      entries.foreach { case (entryName, bytes) =>
+        val entry = new ZipArchiveEntry(entryName)
+        entry.setSize(bytes.length.toLong)
+        out.putArchiveEntry(entry)
+        out.write(bytes)
+        out.closeArchiveEntry()
+      }
+      out.finish()
+    } finally out.close()
+  }
+
+  /**
+   * Writes bytes that are not a readable zip archive to `dest`. The leading 
bytes are not a local
+   * file header signature, so `ZipArchiveInputStream` fails when the first 
entry is read (rather
+   * than silently reporting an empty archive).
+   */
+  protected def writeCorruptArchive(dest: File): Unit =
+    Files.write(dest.toPath, "this is not a valid zip archive, just some 
random bytes"
+      .getBytes(StandardCharsets.UTF_8))
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to