You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by gg...@apache.org on 2025/04/18 17:26:22 UTC

(commons-compress) branch master updated (05afb04f6 -> 1e4e5487d)

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

ggregory pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/commons-compress.git


    from 05afb04f6 org.apache.commons.compress.harmony.unpack200.Archive.unpack() should not log to system out (the console)
     new 979023123 Comments
     new 5829240bf Add org.apache.commons.compress.compressors.xz.XZCompressorInputStream.builder/Builder()
     new 54de661b6 Javadoc
     new 1e4e5487d Javadoc

The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 pom.xml                                            |   5 +
 src/changes/changes.xml                            |   2 +
 .../compressors/CompressorStreamFactory.java       |  15 ++-
 .../compressors/xz/XZCompressorInputStream.java    | 119 ++++++++++++++++++---
 .../compressors/xz/XZCompressorOutputStream.java   |  86 ++++++++++++++-
 .../zstandard/ZstdCompressorOutputStream.java      |   2 +
 .../compress/harmony/pack200/ClassBands.java       |  72 ++++++++++++-
 .../commons/compress/harmony/pack200/CpBands.java  |  12 +++
 .../compressors/xz/XZCompressorRoundtripTest.java  | 117 ++++++++++++++++++++
 9 files changed, 408 insertions(+), 22 deletions(-)
 create mode 100644 src/test/java/org/apache/commons/compress/compressors/xz/XZCompressorRoundtripTest.java


(commons-compress) 01/04: Comments

Posted by gg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ggregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-compress.git

commit 97902312362779760e391675724434d8b0e939bf
Author: Gary Gregory <ga...@gmail.com>
AuthorDate: Thu Apr 17 15:57:03 2025 -0400

    Comments
---
 .../compress/compressors/zstandard/ZstdCompressorOutputStream.java      | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/main/java/org/apache/commons/compress/compressors/zstandard/ZstdCompressorOutputStream.java b/src/main/java/org/apache/commons/compress/compressors/zstandard/ZstdCompressorOutputStream.java
index 3ae1bb360..78eaf3fb6 100644
--- a/src/main/java/org/apache/commons/compress/compressors/zstandard/ZstdCompressorOutputStream.java
+++ b/src/main/java/org/apache/commons/compress/compressors/zstandard/ZstdCompressorOutputStream.java
@@ -88,9 +88,11 @@ public ZstdCompressorOutputStream(final OutputStream outStream, final int level,
     public ZstdCompressorOutputStream(final OutputStream outStream, final int level, final boolean closeFrameOnFlush, final boolean useChecksum)
             throws IOException {
         super(new ZstdOutputStream(outStream, level));
+        // @formatter:off
         out()
             .setCloseFrameOnFlush(closeFrameOnFlush)
             .setChecksum(useChecksum);
+        // @formatter:on
     }
 
     @Override


(commons-compress) 03/04: Javadoc

Posted by gg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ggregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-compress.git

commit 54de661b6b7e50392fd91eae132a8ade9324c7a0
Author: Gary Gregory <ga...@gmail.com>
AuthorDate: Fri Apr 18 13:25:14 2025 -0400

    Javadoc
---
 .../org/apache/commons/compress/harmony/pack200/CpBands.java | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/src/main/java/org/apache/commons/compress/harmony/pack200/CpBands.java b/src/main/java/org/apache/commons/compress/harmony/pack200/CpBands.java
index 37deb5685..d28e8b05e 100644
--- a/src/main/java/org/apache/commons/compress/harmony/pack200/CpBands.java
+++ b/src/main/java/org/apache/commons/compress/harmony/pack200/CpBands.java
@@ -184,6 +184,12 @@ public CPConstant<?> getConstant(final Object value) {
         return constant;
     }
 
+    /**
+     * Gets a constant pool class for the given class name.
+     *
+     * @param className a fully-qualifed class name.
+     * @return a a constant pool class.
+     */
     public CPClass getCPClass(String className) {
         if (className == null) {
             return null;
@@ -261,6 +267,12 @@ public CPNameAndType getCPNameAndType(final String name, final String signature)
         return nameAndType;
     }
 
+    /**
+     * Gets a constant pool signature.
+     *
+     * @param signature the signature string.
+     * @return a constant pool signature.
+     */
     public CPSignature getCPSignature(final String signature) {
         if (signature == null) {
             return null;


(commons-compress) 04/04: Javadoc

Posted by gg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ggregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-compress.git

commit 1e4e5487d4613822e63c6e1ca5e45e019273b334
Author: Gary Gregory <ga...@gmail.com>
AuthorDate: Fri Apr 18 13:25:22 2025 -0400

    Javadoc
---
 .../compress/harmony/pack200/ClassBands.java       | 72 ++++++++++++++++++++--
 1 file changed, 67 insertions(+), 5 deletions(-)

diff --git a/src/main/java/org/apache/commons/compress/harmony/pack200/ClassBands.java b/src/main/java/org/apache/commons/compress/harmony/pack200/ClassBands.java
index af463b624..719c503a0 100644
--- a/src/main/java/org/apache/commons/compress/harmony/pack200/ClassBands.java
+++ b/src/main/java/org/apache/commons/compress/harmony/pack200/ClassBands.java
@@ -31,6 +31,7 @@
 
 import org.apache.commons.compress.harmony.pack200.AttributeDefinitionBands.AttributeDefinition;
 import org.apache.commons.compress.harmony.pack200.IcBands.IcTuple;
+import org.apache.commons.compress.harmony.pack200.Segment.SegmentMethodVisitor;
 import org.objectweb.asm.Label;
 import org.objectweb.asm.Opcodes;
 
@@ -351,6 +352,11 @@ public void addClass(final int major, final int flags, final String className, f
         }
     }
 
+    /**
+     * Adds a new class attribute.
+     *
+     * @param attribute the new class attribute.
+     */
     public void addClassAttribute(final NewAttribute attribute) {
         // TODO: backwards calls
         final String attributeName = attribute.type;
@@ -373,6 +379,11 @@ public void addCode() {
         }
     }
 
+    /**
+     * Adds a new code attribute.
+     *
+     * @param attribute the new code attribute.
+     */
     public void addCodeAttribute(final NewAttribute attribute) {
         final String attributeName = attribute.type;
         for (final NewAttributeBands bands : codeAttributeBands) {
@@ -387,10 +398,17 @@ public void addCodeAttribute(final NewAttribute attribute) {
         throw new IllegalArgumentException("No suitable definition for " + attributeName);
     }
 
-    public void addEnclosingMethod(final String owner, final String name, final String desc) {
+    /**
+     * Adds an enclosing method.
+     *
+     * @param ownerClassName a fully-qualifed class name.
+     * @param name a method name.
+     * @param signature a signature in the constant pool.
+     */
+    public void addEnclosingMethod(final String ownerClassName, final String name, final String signature) {
         class_flags[index] |= 1 << 18;
-        classEnclosingMethodClass.add(cpBands.getCPClass(owner));
-        classEnclosingMethodDesc.add(name == null ? null : cpBands.getCPNameAndType(name, desc));
+        classEnclosingMethodClass.add(cpBands.getCPClass(ownerClassName));
+        classEnclosingMethodDesc.add(name == null ? null : cpBands.getCPNameAndType(name, signature));
     }
 
     public void addField(int flags, final String name, final String desc, final String signature, final Object value) {
@@ -415,6 +433,11 @@ public void addField(int flags, final String name, final String desc, final Stri
         tempFieldFlags.add(Long.valueOf(flags));
     }
 
+    /**
+     * Adds a new field attribute.
+     *
+     * @param attribute the new field attribute.
+     */
     public void addFieldAttribute(final NewAttribute attribute) {
         final String attributeName = attribute.type;
         for (final NewAttributeBands bands : fieldAttributeBands) {
@@ -514,6 +537,11 @@ public void addMethod(int flags, final String name, final String desc, final Str
         }
     }
 
+    /**
+     * Adds a new method attribute.
+     *
+     * @param attribute the new method attribute.
+     */
     public void addMethodAttribute(final NewAttribute attribute) {
         final String attributeName = attribute.type;
         for (final NewAttributeBands bands : methodAttributeBands) {
@@ -548,6 +576,11 @@ public void addParameterAnnotation(final int parameter, final String desc, final
         }
     }
 
+    /**
+     * Adds a source file path.
+     *
+     * @param source a source file path.
+     */
     public void addSourceFile(final String source) {
         String implicitSourceFileName = class_this[index].toString();
         if (implicitSourceFileName.indexOf('$') != -1) {
@@ -610,8 +643,12 @@ public void doBciRenumbering(final IntList bciRenumbering, final Map<Label, Inte
         }
     }
 
-    public void endOfClass() { // All the data for the current class has been
-                               // read
+    /**
+     * Called when all the data for the current class has been.
+     *
+     * @see Segment#visitEnd()
+     */
+    public void endOfClass() {
         final int numFields = tempFieldDesc.size();
         class_field_count[index] = numFields;
         field_descr[index] = new CPNameAndType[numFields];
@@ -635,6 +672,11 @@ public void endOfClass() { // All the data for the current class has been
         index++;
     }
 
+    /**
+     * Called when all the data for the current method has been.
+     *
+     * @see SegmentMethodVisitor#visitEnd()
+     */
     public void endOfMethod() {
         if (tempMethodRVPA != null) {
             method_RVPA_bands.addParameterAnnotation(tempMethodRVPA.numParams, tempMethodRVPA.annoN, tempMethodRVPA.pairN, tempMethodRVPA.typeRS,
@@ -851,14 +893,29 @@ private int[] getInts(final CPClass[] cpClasses) {
         return ints;
     }
 
+    /**
+     * Tests whether there are any synthetic classes.
+     *
+     * @return whether there are any synthetic classes.
+     */
     public boolean isAnySyntheticClasses() {
         return anySyntheticClasses;
     }
 
+    /**
+     * Tests whether there are any synthetic fields.
+     *
+     * @return whether there are any synthetic fields.
+     */
     public boolean isAnySyntheticFields() {
         return anySyntheticFields;
     }
 
+    /**
+     * Tests whether there are any synthetic methods.
+     *
+     * @return whether there are any synthetic methods.
+     */
     public boolean isAnySyntheticMethods() {
         return anySyntheticMethods;
     }
@@ -878,6 +935,11 @@ private boolean isInnerClassOf(final String possibleInner, final CPClass possibl
         return false;
     }
 
+    /**
+     * Gets the number of classes processed.
+     *
+     * @return the number of classes processed.
+     */
     public int numClassesProcessed() {
         return index;
     }


(commons-compress) 02/04: Add org.apache.commons.compress.compressors.xz.XZCompressorInputStream.builder/Builder()

Posted by gg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ggregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-compress.git

commit 5829240bfd3531639767e40ddf9608b84d3b235c
Author: Gary Gregory <ga...@gmail.com>
AuthorDate: Fri Apr 18 10:37:38 2025 -0400

    Add org.apache.commons.compress.compressors.xz.XZCompressorInputStream.builder/Builder()
    
    - Add org.apache.commons.compress.compressors.xz.XZCompressorOutputStream.builder/Builder()
    - Add org.apache.commons.compress.compressors.xz.XZCompressorRoundtripTest
---
 pom.xml                                            |   5 +
 src/changes/changes.xml                            |   2 +
 .../compressors/CompressorStreamFactory.java       |  15 ++-
 .../compressors/xz/XZCompressorInputStream.java    | 119 ++++++++++++++++++---
 .../compressors/xz/XZCompressorOutputStream.java   |  86 ++++++++++++++-
 .../compressors/xz/XZCompressorRoundtripTest.java  | 117 ++++++++++++++++++++
 6 files changed, 327 insertions(+), 17 deletions(-)

diff --git a/pom.xml b/pom.xml
index 00e317295..4db05eb31 100644
--- a/pom.xml
+++ b/pom.xml
@@ -210,6 +210,11 @@ Brotli, Zstandard and ar, cpio, jar, tar, zip, dump, 7z, arj.
       <artifactId>commons-lang3</artifactId>
       <version>3.17.0</version>
     </dependency>    
+    <dependency>
+      <groupId>org.junit-pioneer</groupId>
+      <artifactId>junit-pioneer</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
   <scm>
     <connection>scm:git:https://gitbox.apache.org/repos/asf/commons-compress.git</connection>
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 86c2aaea6..0ee09ff17 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -112,6 +112,8 @@ The <action> type attribute can be add,update,fix,remove.
       <action type="add" issue="COMPRESS-697" dev="ggregory" due-to="Fredrik Kjellberg, Gary Gregory">Move BitStream.nextBit() method to BitInputStream #663.</action>
       <action type="add" dev="ggregory" due-to="Gary Gregory">Add org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream.builder/Builder().</action>
       <action type="add" dev="ggregory" due-to="Gary Gregory">Add org.apache.commons.compress.compressors.lzma.LZMACompressorOutputStream.builder/Builder().</action>
+      <action type="add" dev="ggregory" due-to="Gary Gregory">Add org.apache.commons.compress.compressors.xz.XZCompressorInputStream.builder/Builder().</action>
+      <action type="add" dev="ggregory" due-to="Gary Gregory">Add org.apache.commons.compress.compressors.xz.XZCompressorOutputStream.builder/Builder().</action>
       <!-- UPDATE -->
       <action type="update" dev="sebb">Bump Commons Parent from 79 to 81</action>
       <action type="update" dev="ggregory" due-to="Dependabot, Gary Gregory">Bump org.apache.commons:commons-parent from 72 to 79 #563, #567, #574, #582, #587, #595.</action>
diff --git a/src/main/java/org/apache/commons/compress/compressors/CompressorStreamFactory.java b/src/main/java/org/apache/commons/compress/compressors/CompressorStreamFactory.java
index c6b1b0993..c02a90097 100644
--- a/src/main/java/org/apache/commons/compress/compressors/CompressorStreamFactory.java
+++ b/src/main/java/org/apache/commons/compress/compressors/CompressorStreamFactory.java
@@ -592,7 +592,12 @@ public CompressorInputStream createCompressorInputStream(final String name, fina
         }
         try {
             if (GZIP.equalsIgnoreCase(name)) {
-                return GzipCompressorInputStream.builder().setInputStream(in).setDecompressConcatenated(actualDecompressConcatenated).get();
+                // @formatter:off
+                return GzipCompressorInputStream.builder()
+                        .setInputStream(in)
+                        .setDecompressConcatenated(actualDecompressConcatenated)
+                        .get();
+                // @formatter:on
             }
             if (BZIP2.equalsIgnoreCase(name)) {
                 return new BZip2CompressorInputStream(in, actualDecompressConcatenated);
@@ -607,7 +612,13 @@ public CompressorInputStream createCompressorInputStream(final String name, fina
                 if (!XZUtils.isXZCompressionAvailable()) {
                     throw new CompressorException("XZ compression is not available." + YOU_NEED_XZ_JAVA);
                 }
-                return new XZCompressorInputStream(in, actualDecompressConcatenated, memoryLimitInKb);
+                // @formatter:off
+                return XZCompressorInputStream.builder()
+                        .setInputStream(in)
+                        .setDecompressConcatenated(actualDecompressConcatenated)
+                        .setMemoryLimitKiB(memoryLimitInKb)
+                        .get();
+                // @formatter:on
             }
             if (ZSTANDARD.equalsIgnoreCase(name)) {
                 if (!ZstdUtils.isZstdCompressionAvailable()) {
diff --git a/src/main/java/org/apache/commons/compress/compressors/xz/XZCompressorInputStream.java b/src/main/java/org/apache/commons/compress/compressors/xz/XZCompressorInputStream.java
index 9b7a88ef9..de7dc24c3 100644
--- a/src/main/java/org/apache/commons/compress/compressors/xz/XZCompressorInputStream.java
+++ b/src/main/java/org/apache/commons/compress/compressors/xz/XZCompressorInputStream.java
@@ -23,19 +23,103 @@
 
 import org.apache.commons.compress.MemoryLimitException;
 import org.apache.commons.compress.compressors.CompressorInputStream;
+import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream;
+import org.apache.commons.compress.compressors.lzma.LZMACompressorOutputStream;
 import org.apache.commons.compress.utils.InputStreamStatistics;
+import org.apache.commons.io.build.AbstractStreamBuilder;
 import org.apache.commons.io.input.BoundedInputStream;
+import org.tukaani.xz.LZMA2Options;
 import org.tukaani.xz.SingleXZInputStream;
 import org.tukaani.xz.XZ;
 import org.tukaani.xz.XZInputStream;
 
+// @formatter:off
 /**
  * XZ decompressor.
- *
+ * <p>
+ * For example:
+ * </p>
+ * <pre>{@code
+ * XZCompressorInputStream s = XZCompressorInputStream.builder()
+ *   .setPath(path)
+ *   .setDecompressConcatenated(false)
+ *   .setMemoryLimitKiB(-1)
+ *   .get();
+ * }
+ * </pre>
  * @since 1.4
  */
+// @formatter:on
 public class XZCompressorInputStream extends CompressorInputStream implements InputStreamStatistics {
 
+    // @formatter:off
+    /**
+     * Builds a new {@link LZMACompressorInputStream}.
+     *
+     * <p>
+     * For example:
+     * </p>
+     * <pre>{@code
+     * XZCompressorInputStream s = XZCompressorInputStream.builder()
+     *   .setPath(path)
+     *   .setDecompressConcatenated(false)
+     *   .setMemoryLimitKiB(-1)
+     *   .get();
+     * }
+     * </pre>
+     *
+     * @see #get()
+     * @see LZMA2Options
+     * @since 1.28.0
+     */
+    // @formatter:on
+    public static class Builder extends AbstractStreamBuilder<XZCompressorInputStream, Builder> {
+
+        private int memoryLimitKiB = -1;
+        private boolean decompressConcatenated;
+
+        @Override
+        public XZCompressorInputStream get() throws IOException {
+            return new XZCompressorInputStream(this);
+        }
+
+        /**
+         * Whether to decompress until the end of the input.
+         *
+         * @param decompressConcatenated if true, decompress until the end of the input; if false, stop after the first .xz stream and leave the input position
+         *                               to point to the next byte after the .xz stream
+         * @return this instance.
+         */
+        public Builder setDecompressConcatenated(final boolean decompressConcatenated) {
+            this.decompressConcatenated = decompressConcatenated;
+            return this;
+        }
+
+
+        /**
+         * Sets a working memory threshold in kibibytes (KiB).
+         *
+         * @param memoryLimitKiB The memory limit used when reading blocks. The memory usage limit is expressed in kibibytes (KiB) or {@code -1} to impose no
+         *                       memory usage limit. If the estimated memory limit is exceeded on {@link #read()}, a {@link MemoryLimitException} is thrown.
+         * @return this instance.
+         */
+        public Builder setMemoryLimitKiB(final int memoryLimitKiB) {
+            this.memoryLimitKiB = memoryLimitKiB;
+            return this;
+        }
+    }
+
+    /**
+     * Constructs a new builder of {@link LZMACompressorOutputStream}.
+     *
+     * @return a new builder of {@link LZMACompressorOutputStream}.
+     * @since 1.28.0
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+
     /**
      * Checks if the signature matches what is expected for a .xz file.
      *
@@ -61,6 +145,16 @@ public static boolean matches(final byte[] signature, final int length) {
 
     private final InputStream in;
 
+    @SuppressWarnings("resource") // Caller closes
+    private XZCompressorInputStream(final Builder builder) throws IOException {
+        countingStream = BoundedInputStream.builder().setInputStream(builder.getInputStream()).get();
+        if (builder.decompressConcatenated) {
+            in = new XZInputStream(countingStream, builder.memoryLimitKiB);
+        } else {
+            in = new SingleXZInputStream(countingStream, builder.memoryLimitKiB);
+        }
+    }
+
     /**
      * Creates a new input stream that decompresses XZ-compressed data from the specified input stream. This doesn't support concatenated .xz files.
      *
@@ -69,7 +163,7 @@ public static boolean matches(final byte[] signature, final int length) {
      *                     this implementation, or the underlying {@code inputStream} throws an exception
      */
     public XZCompressorInputStream(final InputStream inputStream) throws IOException {
-        this(inputStream, false);
+        this(builder().setInputStream(inputStream));
     }
 
     /**
@@ -81,9 +175,11 @@ public XZCompressorInputStream(final InputStream inputStream) throws IOException
      *
      * @throws IOException if the input is not in the .xz format, the input is corrupt or truncated, the .xz headers specify options that are not supported by
      *                     this implementation, or the underlying {@code inputStream} throws an exception
+     * @deprecated Use {@link #builder()}.
      */
+    @Deprecated
     public XZCompressorInputStream(final InputStream inputStream, final boolean decompressConcatenated) throws IOException {
-        this(inputStream, decompressConcatenated, -1);
+        this(builder().setInputStream(inputStream).setDecompressConcatenated(decompressConcatenated));
     }
 
     /**
@@ -92,23 +188,22 @@ public XZCompressorInputStream(final InputStream inputStream, final boolean deco
      * @param inputStream            where to read the compressed data
      * @param decompressConcatenated if true, decompress until the end of the input; if false, stop after the first .xz stream and leave the input position to
      *                               point to the next byte after the .xz stream
-     * @param memoryLimitInKb        memory limit used when reading blocks. If the estimated memory limit is exceeded on {@link #read()}, a
-     *                               {@link MemoryLimitException} is thrown.
+     * @param memoryLimitKiB         The memory limit used when reading blocks. The memory usage limit is expressed in kibibytes (KiB) or {@code -1} to impose
+     *                               no memory usage limit. If the estimated memory limit is exceeded on {@link #read()}, a {@link MemoryLimitException} is
+     *                               thrown.
      *
      * @throws IOException if the input is not in the .xz format, the input is corrupt or truncated, the .xz headers specify options that are not supported by
      *                     this implementation, or the underlying {@code inputStream} throws an exception
      *
+     * @deprecated Use {@link #builder()}.
      * @since 1.14
      */
-    public XZCompressorInputStream(final InputStream inputStream, final boolean decompressConcatenated, final int memoryLimitInKb) throws IOException {
-        countingStream = BoundedInputStream.builder().setInputStream(inputStream).get();
-        if (decompressConcatenated) {
-            in = new XZInputStream(countingStream, memoryLimitInKb);
-        } else {
-            in = new SingleXZInputStream(countingStream, memoryLimitInKb);
-        }
+    @Deprecated
+    public XZCompressorInputStream(final InputStream inputStream, final boolean decompressConcatenated, final int memoryLimitKiB) throws IOException {
+        this(builder().setInputStream(inputStream).setDecompressConcatenated(decompressConcatenated).setMemoryLimitKiB(memoryLimitKiB));
     }
 
+
     @Override
     public int available() throws IOException {
         return in.available();
diff --git a/src/main/java/org/apache/commons/compress/compressors/xz/XZCompressorOutputStream.java b/src/main/java/org/apache/commons/compress/compressors/xz/XZCompressorOutputStream.java
index f59958da1..23a04d60f 100644
--- a/src/main/java/org/apache/commons/compress/compressors/xz/XZCompressorOutputStream.java
+++ b/src/main/java/org/apache/commons/compress/compressors/xz/XZCompressorOutputStream.java
@@ -22,13 +22,25 @@
 import java.io.OutputStream;
 
 import org.apache.commons.compress.compressors.CompressorOutputStream;
+import org.apache.commons.io.build.AbstractStreamBuilder;
 import org.tukaani.xz.LZMA2Options;
 import org.tukaani.xz.XZOutputStream;
 
+// @formatter:off
 /**
  * Compresses an output stream using the XZ and LZMA2 compression options.
+ * <p>
+ * For example:
+ * </p>
+ * <pre>{@code
+ * XZCompressorOutputStream s = XZCompressorOutputStream.builder()
+ *   .setPath(path)
+ *   .setLzma2Options(new LZMA2Options(...))
+ *   .get();
+ * }
+ * </pre>
  *
- * <em>Calling flush()</em>
+ * <h2>Calling flush</h2>
  * <p>
  * Calling {@link #flush()} flushes the encoder and calls {@code outputStream.flush()}. All buffered pending data will then be decompressible from the output
  * stream. Calling this function very often may increase the compressed file size a lot.
@@ -36,17 +48,83 @@
  *
  * @since 1.4
  */
+// @formatter:on
 public class XZCompressorOutputStream extends CompressorOutputStream<XZOutputStream> {
 
+    // @formatter:off
+    /**
+     * Builds a new {@link XZCompressorOutputStream}.
+     *
+     * <p>
+     * For example:
+     * </p>
+     * <pre>{@code
+     * XZCompressorOutputStream s = XZCompressorOutputStream.builder()
+     *   .setPath(path)
+     *   .setLzma2Options(new LZMA2Options(...))
+     *   .get();
+     * }
+     * </pre>
+     *
+     * @see #get()
+     * @since 1.28.0
+     */
+    // @formatter:on
+    public static class Builder extends AbstractStreamBuilder<XZCompressorOutputStream, Builder> {
+
+        private LZMA2Options lzma2Options = new LZMA2Options();
+
+        /**
+         * Constructs a new builder of {@link XZCompressorOutputStream}.
+         */
+        public Builder() {
+            // empty
+        }
+
+        @Override
+        public XZCompressorOutputStream get() throws IOException {
+            return new XZCompressorOutputStream(this);
+        }
+
+        /**
+         * Sets LZMA options.
+         * <p>
+         * Passing {@code null} resets to the default value {@link LZMA2Options#LZMA2Options()}.
+         * </p>
+         *
+         * @param lzma2Options LZMA options.
+         * @return this instance.
+         */
+        public Builder setLzma2Options(final LZMA2Options lzma2Options) {
+            this.lzma2Options = lzma2Options != null ? lzma2Options : new LZMA2Options();
+            return this;
+        }
+
+    }
+
+    /**
+     * Constructs a new builder of {@link XZCompressorOutputStream}.
+     *
+     * @return a new builder of {@link XZCompressorOutputStream}.
+     * @since 1.28.0
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    @SuppressWarnings("resource") // Caller closes
+    private XZCompressorOutputStream(final Builder builder) throws IOException {
+        super(new XZOutputStream(builder.getOutputStream(), builder.lzma2Options));
+        }
+
     /**
      * Creates a new XZ compressor using the default LZMA2 options. This is equivalent to {@code XZCompressorOutputStream(outputStream, 6)}.
      *
      * @param outputStream the stream to wrap
      * @throws IOException on error
      */
-    @SuppressWarnings("resource") // Caller closes
     public XZCompressorOutputStream(final OutputStream outputStream) throws IOException {
-        super(new XZOutputStream(outputStream, new LZMA2Options()));
+        this(builder().setOutputStream(outputStream));
     }
 
     /**
@@ -62,7 +140,9 @@ public XZCompressorOutputStream(final OutputStream outputStream) throws IOExcept
      * @param outputStream the stream to wrap
      * @param preset       the preset
      * @throws IOException on error
+     * @deprecated Use {@link #builder()}.
      */
+    @Deprecated
     @SuppressWarnings("resource") // Caller closes
     public XZCompressorOutputStream(final OutputStream outputStream, final int preset) throws IOException {
         super(new XZOutputStream(outputStream, new LZMA2Options(preset)));
diff --git a/src/test/java/org/apache/commons/compress/compressors/xz/XZCompressorRoundtripTest.java b/src/test/java/org/apache/commons/compress/compressors/xz/XZCompressorRoundtripTest.java
new file mode 100644
index 000000000..9b24e9710
--- /dev/null
+++ b/src/test/java/org/apache/commons/compress/compressors/xz/XZCompressorRoundtripTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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
+ *
+ *   https://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.commons.compress.compressors.xz;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junitpioneer.jupiter.cartesian.CartesianTest;
+import org.junitpioneer.jupiter.cartesian.CartesianTest.Values;
+import org.tukaani.xz.LZMA2Options;
+import org.tukaani.xz.XZOutputStream;
+
+/**
+ * Tests for class {@link XZCompressorOutputStream} and {@link XZCompressorInputStream}.
+ *
+ * @see XZCompressorOutputStream
+ * @see XZCompressorInputStream
+ */
+public class XZCompressorRoundtripTest {
+
+    @TempDir
+    static Path tempDir;
+
+    private void roundtrip(final Path outPath, final LZMA2Options options, final boolean decompressConcatenated, final int memoryLimitKiB) throws IOException {
+        final String data = "Hello World!";
+        // @formatter:off
+        try (XZCompressorOutputStream out = XZCompressorOutputStream.builder()
+                .setPath(outPath)
+                .setLzma2Options(options)
+                .get()) {
+            out.writeUtf8(data);
+        }
+        try (XZCompressorInputStream out = XZCompressorInputStream.builder()
+                .setPath(outPath)
+                .setDecompressConcatenated(decompressConcatenated)
+                .setMemoryLimitKiB(memoryLimitKiB)
+                .get()) {
+            assertEquals(data, IOUtils.toString(out, StandardCharsets.UTF_8));
+        }
+        // @formatter:on
+        try (XZCompressorInputStream out = new XZCompressorInputStream(Files.newInputStream(outPath))) {
+            assertEquals(data, IOUtils.toString(out, StandardCharsets.UTF_8));
+        }
+        // deprecated
+        try (XZCompressorOutputStream out = new XZCompressorOutputStream(new XZOutputStream(Files.newOutputStream(outPath), options))) {
+            out.writeUtf8(data);
+        }
+    }
+
+    @Test
+    public void testBuilderOptionsAll() throws IOException {
+        final int dictSize = LZMA2Options.DICT_SIZE_MIN;
+        final int lc = LZMA2Options.LC_LP_MAX - 4;
+        final int lp = LZMA2Options.LC_LP_MAX - 4;
+        final int pb = LZMA2Options.PB_MAX;
+        final int mode = LZMA2Options.MODE_NORMAL;
+        final int niceLen = LZMA2Options.NICE_LEN_MIN;
+        final int mf = LZMA2Options.MF_BT4;
+        final int depthLimit = 50;
+        roundtrip(tempDir.resolve("out.xz"), new LZMA2Options(dictSize, lc, lp, pb, mode, niceLen, mf, depthLimit), false, -1);
+    }
+
+    @CartesianTest
+    public void testBuilderOptions(@Values(ints = { LZMA2Options.PRESET_MAX, LZMA2Options.PRESET_MIN, LZMA2Options.PRESET_DEFAULT }) final int preset,
+            @Values(booleans = { false, true }) final boolean decompressConcatenated, @Values(ints = { -1, 100_000 }) final int memoryLimitKiB)
+            throws IOException {
+        roundtrip(tempDir.resolve("out.xz"), new LZMA2Options(preset), false, -1);
+    }
+
+    @CartesianTest
+    public void testBuilderOptionsDefgault(@Values(booleans = { false, true }) final boolean decompressConcatenated,
+            @Values(ints = { -1, 100_000 }) final int memoryLimitKiB) throws IOException {
+        roundtrip(tempDir.resolve("out.xz"), new LZMA2Options(), decompressConcatenated, memoryLimitKiB);
+    }
+
+    @Test
+    public void testBuilderPath() throws IOException {
+        // This test does not use LZMA2Options
+        final String data = "Hello World!";
+        final Path outPath = tempDir.resolve("out.xz");
+        try (XZCompressorOutputStream out = XZCompressorOutputStream.builder().setPath(outPath).get()) {
+            out.writeUtf8(data);
+        }
+        // deprecated
+        try (XZCompressorInputStream out = new XZCompressorInputStream(Files.newInputStream(outPath), false)) {
+            assertEquals(data, IOUtils.toString(out, StandardCharsets.UTF_8));
+        }
+        // deprecated
+        try (XZCompressorInputStream out = new XZCompressorInputStream(Files.newInputStream(outPath), false, -1)) {
+            assertEquals(data, IOUtils.toString(out, StandardCharsets.UTF_8));
+        }
+    }
+}