Repository: scikit-hep/uproot5

Issue:
Use cramjam to provide compression utilities
@lgray mentioned https://pypi.org/project/cramjam/ to me and it looks like a nice solution to provide many of the ROOT compression algorithms in a single dependency-free package. Additionally, it allows to declare the output length so it can pre-allocate the buffer, which may provide some speedup for algorithms other than lz4, which is the only one currently using the uncompressed size hint:
https://github.com/scikit-hep/uproot5/blob/fd0637bf978d75619e125a7f624827348f757865/src/uproot/compression.py#L194

This is an internal feature and would not provide any user enhancement other than a potential speed-up


Discussion:
Just to finish the chain this was mentioned to me by @martindurant.
> it allows to declare the output length so it can pre-allocate the buffer

You can also allocate buffers yourself and decompress_into - I don't know if there's a use case for that.
Actually, yes! One thing uproot is often doing is decompressing many small chunks and then concatenating them into a larger contiguous buffer. We could save some additional allocation and copy time if we can decompress into a buffer at an arbitrary offset.
> if we can decompress into a buffer at an arbitrary offset.

yes, certainly you can, I think by just slicing the base numpy array
Since we want Uproot to work in Pyodide, it's important to note that [cramjam works in Pyodide](https://github.com/pyodide/pyodide/tree/main/packages/cramjam).

![image](https://github.com/scikit-hep/uproot5/assets/1852447/df38ff41-df74-4d90-9e8a-d61114549223)

Writing into a single, contiguous buffer with `decompress_into` would require some rearchitecting—possible, but a major project. Also, it could only work for non-ragged data (or only the outer indexes of ragged data). It could perhaps be an extension of [uproot.AsDtypeInPlace](https://uproot.readthedocs.io/en/latest/uproot.interpretation.numerical.AsDtypeInPlace.html).

Resolving patch:
diff --git a/src/uproot/compression.py b/src/uproot/compression.py
--- a/src/uproot/compression.py
+++ b/src/uproot/compression.py
@@ -8,7 +8,6 @@
 from __future__ import annotations
 
 import struct
-import threading
 
 import numpy
 
@@ -88,11 +87,26 @@ class _DecompressZLIB:
     name = "ZLIB"
     _2byte = b"ZL"
     _method = b"\x08"
+    library = "zlib"  # options: "zlib", "isal"
 
     def decompress(self, data: bytes, uncompressed_bytes=None) -> bytes:
-        import zlib
+        if uncompressed_bytes is None:
+            raise ValueError(
+                "zlib decompression requires the number of uncompressed bytes"
+            )
+        if self.library == "zlib":
+            import zlib
 
-        return zlib.decompress(data)
+            return zlib.decompress(data, bufsize=uncompressed_bytes)
+
+        elif self.library == "isal":
+            isal_zlib = uproot.extras.isal().isal_zlib
+            return isal_zlib.decompress(data, bufsize=uncompressed_bytes)
+
+        else:
+            raise ValueError(
+                f"unrecognized ZLIB.library: {self.library!r}; must be one of ['zlib', 'isal']"
+            )
 
 
 class ZLIB(Compression, _DecompressZLIB):
@@ -103,7 +117,10 @@ class ZLIB(Compression, _DecompressZLIB):
 
     Represents the ZLIB compression algorithm.
 
-    Uproot uses ``zlib`` from the Python standard library.
+    If ``ZLIB.library`` is ``"zlib"`` (default), Uproot uses ``zlib`` from the
+    Python standard library.
+
+    If ``ZLIB.library`` is ``"isal"`` (default), Uproot uses ``isal.isal_zlib``.
     """
 
     def __init__(self, level):
@@ -115,6 +132,10 @@ def level(self):
         """
         The compression level: 0 is uncompressed, 1 is minimally compressed, and
         9 is maximally compressed.
+
+        This value to adapted to the ISAL compression levels if that library is used.
+        Note: with ISAL 0 is lowest compression, not uncompressed!
+              as such, we don't allow 0 in isal mode for compatibility reasons.
         """
         return self._level
 
@@ -127,9 +148,24 @@ def level(self, value):
         self._level = int(value)
 
     def compress(self, data: bytes) -> bytes:
-        import zlib
+        if self.library == "zlib":
+            import zlib
+
+            return zlib.compress(data, level=self._level)
+
+        elif self.library == "isal":
+            isal_zlib = uproot.extras.isal().isal_zlib
+            if self._level == 0:
+                raise ValueError(
+                    'ZLIB.library="isal", and therefore requesting no compression '
+                    "implicitly with level 0 is not allowed."
+                )
+            return isal_zlib.compress(data, level=round(self._level / 3))
 
-        return zlib.compress(data, self._level)
+        else:
+            raise ValueError(
+                f"unrecognized ZLIB.library: {self.library!r}; must be one of ['zlib', 'isal']"
+            )
 
 
 class _DecompressLZMA:
@@ -138,9 +174,19 @@ class _DecompressLZMA:
     _method = b"\x00"
 
     def decompress(self, data: bytes, uncompressed_bytes=None) -> bytes:
-        import lzma
-
-        return lzma.decompress(data)
+        cramjam = uproot.extras.cramjam()
+        lzma = getattr(cramjam, "lzma", None) or getattr(
+            getattr(cramjam, "experimental", None), "lzma", None
+        )
+        if lzma is None:
+            raise RuntimeError(
+                "lzma not found in the cramjam package! (requires cramjam >= 2.8.1)"
+            )
+        if uncompressed_bytes is None:
+            raise ValueError(
+                "lzma decompression requires the number of uncompressed bytes"
+            )
+        return lzma.decompress(data, output_len=uncompressed_bytes)
 
 
 class LZMA(Compression, _DecompressLZMA):
@@ -151,7 +197,7 @@ class LZMA(Compression, _DecompressLZMA):
 
     Represents the LZMA compression algorithm.
 
-    Uproot uses ``lzma`` from the Python 3 standard library.
+    Uproot uses ``lzma`` from the ``cramjam`` package.
     """
 
     def __init__(self, level):
@@ -175,8 +221,14 @@ def level(self, value):
         self._level = int(value)
 
     def compress(self, data: bytes) -> bytes:
-        import lzma
-
+        cramjam = uproot.extras.cramjam()
+        lzma = getattr(cramjam, "lzma", None) or getattr(
+            getattr(cramjam, "experimental", None), "lzma", None
+        )
+        if lzma is None:
+            raise RuntimeError(
+                "lzma not found in the cramjam package! (requires cramjam >= 2.8.1)"
+            )
         return lzma.compress(data, preset=self._level)
 
 
@@ -186,12 +238,12 @@ class _DecompressLZ4:
     _method = b"\x01"
 
     def decompress(self, data: bytes, uncompressed_bytes=None) -> bytes:
-        lz4_block = uproot.extras.lz4_block()
+        lz4 = uproot.extras.cramjam().lz4
         if uncompressed_bytes is None:
             raise ValueError(
                 "lz4 block decompression requires the number of uncompressed bytes"
             )
-        return lz4_block.decompress(data, uncompressed_size=uncompressed_bytes)
+        return lz4.decompress_block(data, output_len=uncompressed_bytes)
 
 
 class LZ4(Compression, _DecompressLZ4):
@@ -202,7 +254,7 @@ class LZ4(Compression, _DecompressLZ4):
 
     Represents the LZ4 compression algorithm.
 
-    The ``zl4`` and ``xxhash`` libraries must be installed.
+    The ``cramjam`` and ``xxhash`` libraries must be installed.
     """
 
     def __init__(self, level):
@@ -226,8 +278,8 @@ def level(self, value):
         self._level = int(value)
 
     def compress(self, data: bytes) -> bytes:
-        lz4_block = uproot.extras.lz4_block()
-        return lz4_block.compress(data, compression=self._level, store_size=False)
+        lz4 = uproot.extras.cramjam().lz4
+        return lz4.compress_block(data, compression=self._level, store_size=False)
 
 
 class _DecompressZSTD:
@@ -235,19 +287,13 @@ class _DecompressZSTD:
     _2byte = b"ZS"
     _method = b"\x01"
 
-    def __init__(self):
-        # ZstdDecompressor resource is not thread-safe
-        self._decompressor = threading.local()
-
-    @property
-    def decompressor(self):
-        if not hasattr(self._decompressor, "obj"):
-            zstandard = uproot.extras.zstandard()
-            self._decompressor.obj = zstandard.ZstdDecompressor()
-        return self._decompressor.obj
-
     def decompress(self, data: bytes, uncompressed_bytes=None) -> bytes:
-        return self.decompressor.decompress(data)
+        zstd = uproot.extras.cramjam().zstd
+        if uncompressed_bytes is None:
+            raise ValueError(
+                "zstd block decompression requires the number of uncompressed bytes"
+            )
+        return zstd.decompress(data, output_len=uncompressed_bytes)
 
 
 class ZSTD(Compression, _DecompressZSTD):
@@ -258,7 +304,7 @@ class ZSTD(Compression, _DecompressZSTD):
 
     Represents the ZSTD compression algorithm.
 
-    The ``zstandard`` library must be installed.
+    The ``cramjam`` library must be installed.
     """
 
     def __init__(self, level):
@@ -282,15 +328,9 @@ def level(self, value):
             raise ValueError("Compression level must be between 0 and 22 (inclusive)")
         self._level = int(value)
 
-    @property
-    def compressor(self):
-        if self._compressor is None:
-            zstandard = uproot.extras.zstandard()
-            self._compressor = zstandard.ZstdCompressor(level=self._level)
-        return self._compressor
-
     def compress(self, data: bytes) -> bytes:
-        return self.compressor.compress(data)
+        zstd = uproot.extras.cramjam().zstd
+        return zstd.compress(data, level=self._level)
 
 
 algorithm_codes = {
diff --git a/src/uproot/extras.py b/src/uproot/extras.py
--- a/src/uproot/extras.py
+++ b/src/uproot/extras.py
@@ -160,70 +160,64 @@ def xrootd_version():
             return None
 
 
-def lz4_block():
+def isal():
     """
-    Imports and returns ``lz4``.
-
-    Attempts to import ``xxhash`` as well.
+    Import and return ``isal``.
     """
     try:
-        import lz4.block
-        import xxhash  # noqa: F401
+        import isal
     except ModuleNotFoundError as err:
         raise ModuleNotFoundError(
-            """install the 'lz4' and `xxhash` packages with:
+            """install the 'cramjam' package with:
 
-    pip install lz4 xxhash
+    pip install isal
 
 or
 
-    conda install lz4 python-xxhash"""
+    conda install python-isal"""
         ) from err
     else:
-        return lz4.block
+        return isal
 
 
-def xxhash():
+def cramjam():
     """
-    Imports and returns ``xxhash``.
-
-    Attempts to import ``lz4`` as well.
+    Import and returns ``cramjam``.
     """
     try:
-        import lz4.block  # noqa: F401
-        import xxhash
+        import cramjam
     except ModuleNotFoundError as err:
         raise ModuleNotFoundError(
-            """install the 'lz4' and `xxhash` packages with:
+            """install the 'cramjam' package with:
 
-    pip install lz4 xxhash
+    pip install cramjam
 
 or
 
-    conda install lz4 python-xxhash"""
+    conda install cramjam"""
         ) from err
     else:
-        return xxhash
+        return cramjam
 
 
-def zstandard():
+def xxhash():
     """
-    Imports and returns ``zstandard``.
+    Imports and returns ``xxhash``.
     """
     try:
-        import zstandard
+        import xxhash
     except ModuleNotFoundError as err:
         raise ModuleNotFoundError(
-            """install the 'zstandard' package with:
+            """install the `xxhash` packages with:
 
-    pip install zstandard
+    pip install xxhash
 
 or
 
-    conda install zstandard"""
+    conda install python-xxhash"""
         ) from err
     else:
-        return zstandard
+        return xxhash
 
 
 def boost_histogram():


Test patch:
diff --git a/tests/test_0008_start_interpretation.py b/tests/test_0008_start_interpretation.py
--- a/tests/test_0008_start_interpretation.py
+++ b/tests/test_0008_start_interpretation.py
@@ -34,7 +34,11 @@ def test_classname_encoding(tmpdir):
     ) == ("namespace::some.deep<templated, thing>", None)
 
 
-def test_file_header():
+@pytest.mark.parametrize("use_isal", [False, True])
+def test_file_header(use_isal):
+    if use_isal:
+        pytest.importorskip("isal")
+    uproot.ZLIB.use_isal = use_isal
     filename = skhep_testdata.data_path("uproot-Zmumu.root")
     file = uproot.reading.ReadOnlyFile(filename)
     assert repr(file.compression) == "ZLIB(4)"
diff --git a/tests/test_0416_writing_compressed_data.py b/tests/test_0416_writing_compressed_data.py
--- a/tests/test_0416_writing_compressed_data.py
+++ b/tests/test_0416_writing_compressed_data.py
@@ -11,7 +11,14 @@
 ROOT = pytest.importorskip("ROOT")
 
 
-def test_ZLIB():
+@pytest.mark.parametrize("use_isal", [False, True])
+def test_ZLIB(use_isal):
+    if use_isal:
+        pytest.importorskip("isal")
+        uproot.ZLIB.library = "isal"
+    else:
+        uproot.ZLIB.library = "zlib"
+
     for _ in range(2):
         with uproot.open(skhep_testdata.data_path("uproot-Zmumu-zlib.root"))[
             "events"
@@ -42,7 +49,7 @@ def test_LZMA():
 
 
 def test_LZ4():
-    pytest.importorskip("lz4")
+    pytest.importorskip("cramjam")
 
     for _ in range(2):
         with uproot.open(skhep_testdata.data_path("uproot-Zmumu-lz4.root"))[
@@ -58,7 +65,7 @@ def test_LZ4():
 
 
 def test_ZSTD():
-    pytest.importorskip("zstandard")
+    pytest.importorskip("cramjam")
 
     for _ in range(2):
         with uproot.open(skhep_testdata.data_path("uproot-Zmumu-zstd.root"))[
@@ -73,7 +80,14 @@ def test_ZSTD():
             ]
 
 
-def test_histogram_ZLIB(tmp_path):
+@pytest.mark.parametrize("use_isal", [False, True])
+def test_histogram_ZLIB(tmp_path, use_isal):
+    if use_isal:
+        pytest.importorskip("isal")
+        uproot.ZLIB.library = "isal"
+    else:
+        uproot.ZLIB.library = "zlib"
+
     newfile = os.path.join(tmp_path, "newfile.root")
 
     SIZE = 2**21
@@ -122,7 +136,7 @@ def test_histogram_LZMA(tmp_path):
 
 
 def test_histogram_LZ4(tmp_path):
-    pytest.importorskip("lz4")
+    pytest.importorskip("cramjam")
 
     newfile = os.path.join(tmp_path, "newfile.root")
 
@@ -147,7 +161,7 @@ def test_histogram_LZ4(tmp_path):
 
 
 def test_histogram_ZSTD(tmp_path):
-    pytest.importorskip("zstandard")
+    pytest.importorskip("cramjam")
 
     newfile = os.path.join(tmp_path, "newfile.root")
 
@@ -216,7 +230,7 @@ def test_flattree_LZMA(tmp_path):
 
 
 def test_flattree_LZ4(tmp_path):
-    pytest.importorskip("lz4")
+    pytest.importorskip("cramjam")
 
     newfile = os.path.join(tmp_path, "newfile.root")
 
@@ -239,7 +253,7 @@ def test_flattree_LZ4(tmp_path):
 
 
 def test_flattree_ZSTD(tmp_path):
-    pytest.importorskip("zstandard")
+    pytest.importorskip("cramjam")
 
     newfile = os.path.join(tmp_path, "newfile.root")
 
@@ -309,7 +323,7 @@ def test_jaggedtree_LZMA(tmp_path):
 
 
 def test_jaggedtree_LZ4(tmp_path):
-    pytest.importorskip("lz4")
+    pytest.importorskip("cramjam")
     ak = pytest.importorskip("awkward")
 
     newfile = os.path.join(tmp_path, "newfile.root")
@@ -333,7 +347,7 @@ def test_jaggedtree_LZ4(tmp_path):
 
 
 def test_jaggedtree_ZSTD(tmp_path):
-    pytest.importorskip("zstandard")
+    pytest.importorskip("cramjam")
     ak = pytest.importorskip("awkward")
 
     newfile = os.path.join(tmp_path, "newfile.root")
@@ -356,7 +370,14 @@ def test_jaggedtree_ZSTD(tmp_path):
     f3.Close()
 
 
-def test_multicompression_1(tmp_path):
+@pytest.mark.parametrize("use_isal", [False, True])
+def test_multicompression_1(tmp_path, use_isal):
+    if use_isal:
+        pytest.importorskip("isal")
+        uproot.ZLIB.library = "isal"
+    else:
+        uproot.ZLIB.library = "zlib"
+
     newfile = os.path.join(tmp_path, "newfile.root")
 
     branch1 = np.arange(100)
@@ -383,7 +404,14 @@ def test_multicompression_1(tmp_path):
     f3.Close()
 
 
-def test_multicompression_2(tmp_path):
+@pytest.mark.parametrize("use_isal", [False, True])
+def test_multicompression_2(tmp_path, use_isal):
+    if use_isal:
+        pytest.importorskip("isal")
+        uproot.ZLIB.library = "isal"
+    else:
+        uproot.ZLIB.library = "zlib"
+
     newfile = os.path.join(tmp_path, "newfile.root")
 
     branch1 = np.arange(100)
@@ -409,7 +437,14 @@ def test_multicompression_2(tmp_path):
     f3.Close()
 
 
-def test_multicompression_3(tmp_path):
+@pytest.mark.parametrize("use_isal", [False, True])
+def test_multicompression_3(tmp_path, use_isal):
+    if use_isal:
+        pytest.importorskip("isal")
+        uproot.ZLIB.library = "isal"
+    else:
+        uproot.ZLIB.library = "zlib"
+
     newfile = os.path.join(tmp_path, "newfile.root")
 
     branch1 = np.arange(100)
@@ -436,7 +471,14 @@ def test_multicompression_3(tmp_path):
     f3.Close()
 
 
-def test_multicompression_4(tmp_path):
+@pytest.mark.parametrize("use_isal", [False, True])
+def test_multicompression_4(tmp_path, use_isal):
+    if use_isal:
+        pytest.importorskip("isal")
+        uproot.ZLIB.library = "isal"
+    else:
+        uproot.ZLIB.library = "zlib"
+
     newfile = os.path.join(tmp_path, "newfile.root")
 
     branch1 = np.arange(100)
@@ -461,7 +503,14 @@ def test_multicompression_4(tmp_path):
     f3.Close()
 
 
-def test_multicompression_5(tmp_path):
+@pytest.mark.parametrize("use_isal", [False, True])
+def test_multicompression_5(tmp_path, use_isal):
+    if use_isal:
+        pytest.importorskip("isal")
+        uproot.ZLIB.library = "isal"
+    else:
+        uproot.ZLIB.library = "zlib"
+
     newfile = os.path.join(tmp_path, "newfile.root")
 
     branch1 = np.arange(100)
diff --git a/tests/test_0962_RNTuple_update.py b/tests/test_0962_RNTuple_update.py
--- a/tests/test_0962_RNTuple_update.py
+++ b/tests/test_0962_RNTuple_update.py
@@ -38,7 +38,7 @@ def test_new_support_RNTuple_split_int16_reading():
         assert np.all(np.unique(df.one_integers[len(df.one_integers) / 2 + 1 :]) == [1])
 
 
-pytest.importorskip("zstandard")
+pytest.importorskip("cramjam")
 
 
 @pytest.mark.xfail(