Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1b34f10
Extract ZIP archives while downloading
hagenw Jan 8, 2026
d80e651
Don't use stream-zip for Python 3.14
hagenw Jan 12, 2026
b168224
Fix dependencies
hagenw Jan 12, 2026
d64b66f
Ensure we show progress bar
hagenw Jan 14, 2026
edaaeab
Get src_size only when needed
hagenw Jan 14, 2026
9c7bc98
Add tests for _size()
hagenw Jan 14, 2026
f31b346
Exclude stream-unzip import from coverage
hagenw Jan 14, 2026
f7c2376
Fix coverage
hagenw Jan 14, 2026
0380441
Move imports to the top
hagenw Jan 14, 2026
3e11ba8
Exclude more files from Python 3.14 coverage
hagenw Jan 14, 2026
edc7a01
Fix returned archives under Windows
hagenw Jan 14, 2026
12c0e71
Fix another test under Windows
hagenw Jan 14, 2026
692a268
Revert "Fix another test under Windows"
hagenw Jan 14, 2026
d6c3ef0
Revert "Fix returned archives under Windows"
hagenw Jan 14, 2026
d6269af
Fix test expectations under Windows
hagenw Jan 14, 2026
c24ad8a
Another try to fix Windows test
hagenw Jan 14, 2026
2dacc65
Revert "Fix test expectations under Windows"
hagenw Jan 14, 2026
ed39ad6
Try to fix Windows
hagenw Jan 14, 2026
e87c76f
Add _size() to base backend
hagenw Jan 14, 2026
5c43477
Fix coverage
hagenw Jan 14, 2026
3d9ddfc
Updates from sourcery feedback
hagenw Jan 15, 2026
0b02bdb
Restore size tests
hagenw Jan 15, 2026
44481d7
Fix test_size for filesystem
hagenw Jan 15, 2026
a0cd0d2
Add tests for _get_file_stream() for all backends
hagenw Jan 15, 2026
aa28b4d
Extend comment
hagenw Jan 15, 2026
b968e9f
Remove pragma
hagenw Jan 15, 2026
4ffd418
Readd num_workers
hagenw Jan 19, 2026
1c5b418
Add missing num_workers
hagenw Jan 19, 2026
39dd915
Recommend num_workers for uncompressed ZIP files
hagenw Jan 21, 2026
d7901b8
Extend docstrings for interfaces as well
hagenw Jan 21, 2026
a65017e
Fix typing of return values
hagenw Jan 27, 2026
59f2c5d
Remove verification statement
hagenw Jan 27, 2026
15b4fff
Improve docstring for num_workers
hagenw Jan 27, 2026
0618602
Fix imports
hagenw Jan 27, 2026
fd6224d
Improve docstring
hagenw Feb 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .coveragerc.python3.14
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ exclude_lines =
omit =
audbackend/core/api.py
audbackend/core/backend/artifactory.py
audbackend/core/backend/base.py
audbackend/core/backend/filesystem.py
audbackend/core/backend/minio.py
21 changes: 21 additions & 0 deletions audbackend/core/backend/artifactory.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,27 @@ def _get_file(
src_path = self.path(src_path)
_download(src_path, dst_path, verbose=verbose)

def _get_file_stream(
self,
src_path: str,
):
Comment thread
hagenw marked this conversation as resolved.
Outdated
r"""Get file from backend as byte stream."""
from audbackend.core.backend.base import STREAM_CHUNK_SIZE

src_path = self.path(src_path)

with src_path.open("r") as fp:
while data := fp.read(STREAM_CHUNK_SIZE):
yield data

def _size(
self,
path: str,
) -> int:
r"""Get size of file on backend."""
path = self.path(path)
return artifactory.ArtifactoryPath.stat(path).size

def _ls(
self,
path: str,
Expand Down
233 changes: 228 additions & 5 deletions audbackend/core/backend/base.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
from collections.abc import Iterator
from collections.abc import Sequence
import errno
import fnmatch
import hashlib
import inspect
import os
import shutil
import tempfile
import zipfile

import audeer


# stream-unzip is optional (not available on Python 3.14+)
try:
from stream_unzip import TruncatedDataError
from stream_unzip import UnfinishedIterationError
from stream_unzip import stream_unzip

STREAM_UNZIP_AVAILABLE = True
except ImportError: # pragma: no cover
STREAM_UNZIP_AVAILABLE = False

from audbackend.core import utils
from audbackend.core.errors import BackendError


# Default chunk size for streaming file operations (64 KB)
STREAM_CHUNK_SIZE = 64 * 1024

backend_not_opened_error = (
"Call 'Backend.open()' to establish a connection to the repository first."
)
Expand Down Expand Up @@ -450,7 +469,23 @@ def get_archive(
) -> list[str]:
r"""Get archive from backend and extract.

The archive type is derived from the extension of ``src_path``.
For ZIP archives,
streaming extraction is used
if ``stream-unzip`` is installed,
and ``num_workers=1``.
It extracts files during download
without storing the archive locally,
and is recommended
as it is faster in most cases.
For uncompressed ZIP archives,
we recommend using ``num_workers=5`` instead
on backends that support multiple workers
(e.g. :class:`audbackend.backend.Minio`).

For other archive types
(or ZIP without ``stream-unzip``),
the archive is downloaded first
and then extracted.
See :func:`audeer.extract_archive` for supported extensions.

If ``dst_root`` does not exist,
Expand All @@ -461,14 +496,18 @@ def get_archive(
``src_path`` and the retrieved archive
have the same checksum.
If it fails,
the retrieved archive is removed and
the extracted files are removed and
an :class:`InterruptedError` is raised.
Note that for ZIP archives with streaming extraction,
the checksum is computed from the downloaded stream,
which requires the full archive to be processed.
Comment thread
hagenw marked this conversation as resolved.
Outdated

Args:
src_path: path to archive on backend
dst_root: local destination directory
tmp_root: directory under which archive is temporarily extracted.
Defaults to temporary directory of system
Defaults to temporary directory of system.
Not used for ZIP archives when ``stream-unzip`` is available.
Comment thread
hagenw marked this conversation as resolved.
num_workers: number of parallel jobs
validate: verify archive was successfully
retrieved from the backend
Expand Down Expand Up @@ -498,10 +537,54 @@ def get_archive(

src_path = utils.check_path(src_path)

# Validate tmp_root if specified
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
# (use TemporaryDirectory to get consistent error format)
if tmp_root is not None:
with tempfile.TemporaryDirectory(dir=tmp_root):
pass

# Use streaming extraction for ZIP files if stream-unzip is available
if (
src_path.lower().endswith(".zip")
and num_workers == 1
and STREAM_UNZIP_AVAILABLE
):
return self._get_archive_streaming(
src_path,
dst_root,
validate=validate,
verbose=verbose,
)

return self._get_archive_via_tempfile(
src_path,
dst_root,
tmp_root=tmp_root,
num_workers=num_workers,
validate=validate,
verbose=verbose,
)

def _get_archive_via_tempfile(
self,
src_path: str,
dst_root: str,
*,
tmp_root: str | None,
num_workers: int,
validate: bool,
verbose: bool,
) -> list[str]:
r"""Get archive via temporary file download and extraction.

Downloads the archive to a temporary location,
then extracts it to the destination.

"""
with tempfile.TemporaryDirectory(dir=tmp_root) as tmp:
tmp_root = audeer.path(tmp, os.path.basename(dst_root))
tmp_dir = audeer.path(tmp, os.path.basename(dst_root))
local_archive = os.path.join(
tmp_root,
tmp_dir,
os.path.basename(src_path),
)
self.get_file(
Expand All @@ -518,6 +601,128 @@ def get_archive(
verbose=verbose,
)

def _cleanup_extracted(
self,
dst_root: str,
dst_root_existed: bool,
extracted_files: list[str],
) -> None:
r"""Remove extracted files and optionally the destination directory.

If we created the destination directory and it didn't exist before,
remove it entirely. Otherwise, only remove the files we extracted.

"""
if not dst_root_existed and os.path.exists(dst_root):
shutil.rmtree(dst_root)
else:
for file_name in extracted_files:
full_path = audeer.path(dst_root, file_name)
if os.path.exists(full_path):
os.remove(full_path)

def _get_archive_streaming(
self,
src_path: str,
dst_root: str,
*,
validate: bool = False,
verbose: bool = False,
Comment thread
hagenw marked this conversation as resolved.
) -> list[str]:
r"""Get ZIP archive from backend with streaming extraction.

Extracts files during download without storing the archive locally.

"""
# Validate dst_root is not an existing file,
# raise same error as audeer.extract_archive()
if os.path.exists(dst_root) and not os.path.isdir(dst_root):
raise NotADirectoryError(
errno.ENOTDIR,
os.strerror(errno.ENOTDIR),
dst_root,
)

# Track if we created the destination directory
dst_root_existed = os.path.exists(dst_root)
audeer.mkdir(dst_root)

extracted_files = []
md5_hash = hashlib.md5() if validate else None

# Get file size for progress bar
src_size = self._size(src_path) if verbose else None

# Setup progress bar
desc = audeer.format_display_message(
f"Download {os.path.basename(src_path)}",
pbar=verbose,
)
pbar = audeer.progress_bar(total=src_size, desc=desc, disable=not verbose)

def stream_with_hash():
"""Wrap stream to compute hash while streaming."""
for chunk in self._get_file_stream(src_path):
if md5_hash is not None:
md5_hash.update(chunk)
pbar.update(len(chunk))
yield chunk

try:
with pbar:
for file_name, file_size, unzipped_chunks in stream_unzip(
stream_with_hash()
):
# Decode file name and handle path
file_name = file_name.decode("utf-8")

# Skip directory entries
if file_name.endswith("/"):
continue

# Construct destination path
dst_path = audeer.path(dst_root, file_name)

# Create parent directories if needed
audeer.mkdir(os.path.dirname(dst_path))

# Write file content
with open(dst_path, "wb") as f:
for chunk in unzipped_chunks:
f.write(chunk)

# Store relative path (consistent with audeer.extract_archive)
extracted_files.append(file_name.replace("/", os.sep))

# Validate checksum if requested
if validate:
expected_checksum = self.checksum(src_path)
actual_checksum = md5_hash.hexdigest()

if actual_checksum != expected_checksum:
self._cleanup_extracted(
dst_root, dst_root_existed, extracted_files
)
raise InterruptedError(
f"Execution is interrupted because "
f"{src_path} "
f"has checksum "
f"'{actual_checksum}' "
"when the expected checksum is "
f"'{expected_checksum}'. "
f"The extracted files have been removed."
)
Comment thread
hagenw marked this conversation as resolved.

except (zipfile.BadZipFile, TruncatedDataError, UnfinishedIterationError) as ex:
self._cleanup_extracted(dst_root, dst_root_existed, extracted_files)
raise RuntimeError(f"Broken archive: {src_path}") from ex
except Exception:
# Clean up on any unexpected error (network errors, etc.)
self._cleanup_extracted(dst_root, dst_root_existed, extracted_files)
raise

return extracted_files

def _get_file(
self,
src_path: str,
Expand All @@ -528,6 +733,17 @@ def _get_file(
r"""Get file from backend."""
raise NotImplementedError()

def _get_file_stream(
self,
src_path: str,
) -> Iterator[bytes]: # pragma: no cover
r"""Get file from backend as byte stream.

This method should return an iterator that yields chunks of bytes.

"""
raise NotImplementedError()

def get_file(
self,
src_path: str,
Expand Down Expand Up @@ -1139,6 +1355,13 @@ def sep(self) -> str:
"""
return utils.BACKEND_SEPARATOR

def _size(
self,
path: str,
) -> int: # pragma: no cover
r"""Get size of file on backend."""
raise NotImplementedError()

def split(
self,
path: str,
Expand Down
21 changes: 21 additions & 0 deletions audbackend/core/backend/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,27 @@ def _get_file(
src_path = self._expand(src_path)
shutil.copy(src_path, dst_path)

def _get_file_stream(
self,
src_path: str,
):
Comment thread
hagenw marked this conversation as resolved.
Outdated
r"""Get file from backend as byte stream."""
from audbackend.core.backend.base import STREAM_CHUNK_SIZE

src_path = self._expand(src_path)

with open(src_path, "rb") as f:
while data := f.read(STREAM_CHUNK_SIZE):
yield data

def _size(
self,
path: str,
) -> int:
r"""Get size of file on backend."""
path = self._expand(path)
return os.path.getsize(path)

def _ls(
self,
path: str,
Expand Down
21 changes: 21 additions & 0 deletions audbackend/core/backend/minio.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,27 @@ def _download_file(
response.close()
response.release_conn()

def _get_file_stream(
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
self,
src_path: str,
):
Comment thread
hagenw marked this conversation as resolved.
Outdated
r"""Get file from backend as byte stream."""
from audbackend.core.backend.base import STREAM_CHUNK_SIZE

src_path = self.path(src_path)

response = self._client.get_object(
bucket_name=self.repository,
object_name=src_path,
)

try:
while data := response.read(STREAM_CHUNK_SIZE):
yield data
finally:
response.close()
response.release_conn()

def _ls(
self,
path: str,
Expand Down
Loading