Skip to content

Commit 9463b19

Browse files
committed
Replace 'aws s3 ls' shell-out in dataset.py with boto3
1 parent 3241adb commit 9463b19

5 files changed

Lines changed: 438 additions & 48 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,6 @@ cython_debug/
163163
*.wav
164164
wandb/*
165165
*.out
166-
test_*
166+
test_*
167+
# macOS
168+
.DS_Store

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,20 @@ The following properties are defined in the top level of the model configuration
171171
## Dataset config
172172
`stable-audio-tools` currently supports two kinds of data sources: local directories of audio files, and WebDataset datasets stored in Amazon S3. More information can be found in [the dataset config documentation](docs/datasets.md)
173173

174+
## S3-compatible storage (Backblaze B2)
175+
The S3 dataset loader uses `boto3`, which ships in the `train` extra. If you installed without that extra, add it with `pip install boto3` (or `pip install "stable-audio-tools[train]"`).
176+
177+
The loader honors the `AWS_ENDPOINT_URL` environment variable, so you can point it at any S3-compatible host without changing the dataset config.
178+
179+
Example for [Backblaze B2](https://www.backblaze.com/cloud-storage):
180+
```bash
181+
export AWS_ENDPOINT_URL=https://s3.us-west-004.backblazeb2.com
182+
export AWS_ACCESS_KEY_ID=<B2 application key ID>
183+
export AWS_SECRET_ACCESS_KEY=<B2 application key>
184+
```
185+
186+
When `AWS_ENDPOINT_URL` is unset, the loader uses default AWS S3, so existing setups are unaffected.
187+
174188
# Todo
175189
- [ ] Add troubleshooting section
176190
- [ ] Add contribution guidelines

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
[project.optional-dependencies]
3838
train = [
3939
"auraloss==0.4.0",
40+
"boto3",
4041
"descript-audio-codec==1.0.0",
4142
"encodec==0.1.1",
4243
"inf-cl",

stable_audio_tools/data/dataset.py

Lines changed: 125 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import importlib
2+
from importlib.metadata import PackageNotFoundError, version
3+
from functools import lru_cache
24
import numpy as np
35
import io
46
import json
57
import os
68
import dill
79
import posixpath
810
import random
9-
import re
10-
import subprocess
1111
import time
1212
import torch
1313
import torchaudio
@@ -483,51 +483,108 @@ def __getitem__(self, idx):
483483

484484
# S3 code and WDS preprocessing code based on implementation by Scott Hawley originally in https://github.com/zqevans/audio-diffusion/blob/main/dataset/dataset.py
485485

486+
try:
487+
_PACKAGE_VERSION = version("stable-audio-tools")
488+
except PackageNotFoundError: # source/editable checkout without dist metadata
489+
_PACKAGE_VERSION = "dev"
490+
491+
_USER_AGENT = f"stable-audio-tools/{_PACKAGE_VERSION}"
492+
493+
494+
def _build_user_agent_extra(user_agent_extra=None):
495+
"""``stable-audio-tools/<version>`` with any caller- or env-provided
496+
(``STABLE_AUDIO_TOOLS_USER_AGENT_EXTRA``) value appended, not replacing it."""
497+
extra = user_agent_extra or os.environ.get("STABLE_AUDIO_TOOLS_USER_AGENT_EXTRA")
498+
return f"{_USER_AGENT} {extra}" if extra else _USER_AGENT
499+
500+
501+
@lru_cache(maxsize=32)
502+
def _build_s3_client(profile, endpoint_url, user_agent_extra):
503+
import boto3 # local import so boto3 is only required when S3 is used
504+
from botocore.config import Config
505+
506+
session = boto3.Session(profile_name=profile) if profile else boto3.Session()
507+
return session.client(
508+
"s3",
509+
endpoint_url=endpoint_url,
510+
config=Config(user_agent_extra=user_agent_extra),
511+
)
512+
513+
514+
def _get_s3_client(profile=None, user_agent_extra=None):
515+
"""
516+
Build (and reuse) a boto3 S3 client. Honors AWS_ENDPOINT_URL when set so the
517+
same code path works against any S3-compatible endpoint (AWS S3 by default;
518+
set AWS_ENDPOINT_URL to a Backblaze B2 endpoint to point it at B2). When the
519+
env var is unset, behavior matches the default AWS client.
520+
521+
Clients are cached per (profile, endpoint, user-agent) so listing and
522+
presigning share one client instead of building a new one on each call.
523+
"""
524+
endpoint_url = os.environ.get("AWS_ENDPOINT_URL") or None
525+
return _build_s3_client(
526+
profile, endpoint_url, _build_user_agent_extra(user_agent_extra)
527+
)
528+
529+
530+
def _parse_s3_url(url):
531+
"Split an ``s3://bucket/key`` URL into (bucket, key). Raises ValueError otherwise."
532+
if not url.startswith("s3://"):
533+
raise ValueError(f"expected an s3:// URL, got: {url!r}")
534+
bucket, _, key = url[len("s3://"):].partition("/")
535+
return bucket, key
536+
537+
486538
def get_s3_contents(dataset_path, s3_url_prefix=None, filter='', recursive=True, debug=False, profile=None):
487539
"""
488-
Returns a list of full S3 paths to files in a given S3 bucket and directory path.
540+
Returns a list of S3 keys (relative to ``dataset_path``) for objects in a
541+
given S3 bucket and directory path. Uses boto3 directly so it works
542+
against any S3-compatible endpoint when ``AWS_ENDPOINT_URL`` is set.
489543
"""
490544
# Ensure dataset_path ends with a trailing slash
491545
if dataset_path != '' and not dataset_path.endswith('/'):
492546
dataset_path += '/'
493-
# Use posixpath to construct the S3 URL path
547+
# Use posixpath to construct the S3 URL path (e.g. "s3://bucket/prefix/")
494548
bucket_path = posixpath.join(s3_url_prefix or '', dataset_path)
495-
# Construct the `aws s3 ls` command
496-
cmd = ['aws', 's3', 'ls', bucket_path]
497549

498-
if profile is not None:
499-
cmd.extend(['--profile', profile])
550+
bucket, prefix = _parse_s3_url(bucket_path)
551+
552+
s3 = _get_s3_client(profile=profile)
553+
paginator = s3.get_paginator("list_objects_v2")
554+
list_kwargs = {"Bucket": bucket, "Prefix": prefix}
555+
if not recursive:
556+
list_kwargs["Delimiter"] = "/"
557+
558+
keys = []
559+
for page in paginator.paginate(**list_kwargs):
560+
for obj in page.get("Contents", []) or []:
561+
key = obj.get("Key", "")
562+
if not key or key.endswith("/"):
563+
continue
564+
keys.append(key)
500565

501-
if recursive:
502-
# Add the --recursive flag if requested
503-
cmd.append('--recursive')
504-
505-
# Run the `aws s3 ls` command and capture the output
506-
run_ls = subprocess.run(cmd, capture_output=True, check=True)
507-
# Split the output into lines and strip whitespace from each line
508-
contents = run_ls.stdout.decode('utf-8').split('\n')
509-
contents = [x.strip() for x in contents if x]
510-
# Remove the timestamp from lines that begin with a timestamp
511-
contents = [re.sub(r'^\S+\s+\S+\s+\d+\s+', '', x)
512-
if re.match(r'^\S+\s+\S+\s+\d+\s+', x) else x for x in contents]
513-
# Construct a full S3 path for each file in the contents list
514-
contents = [posixpath.join(s3_url_prefix or '', x)
515-
for x in contents if not x.endswith('/')]
516566
# Apply the filter, if specified
517567
if filter:
518-
contents = [x for x in contents if filter in x]
519-
# Remove redundant directory names in the S3 URL
520-
if recursive:
521-
# Get the main directory name from the S3 URL
522-
main_dir = "/".join(bucket_path.split('/')[3:])
523-
# Remove the redundant directory names from each file path
524-
contents = [x.replace(f'{main_dir}', '').replace(
525-
'//', '/') for x in contents]
526-
# Print debugging information, if requested
568+
keys = [k for k in keys if filter in k]
569+
570+
# Match the legacy `aws s3 ls` output shape: paths relative to dataset_path.
571+
# The legacy CLI emitted basenames in non-recursive mode and full keys
572+
# (which it then stripped) in recursive mode; both paths ended up
573+
# relative to dataset_path. boto3 always returns full keys, so strip
574+
# the prefix unconditionally.
575+
if prefix:
576+
keys = [k[len(prefix):] if k.startswith(prefix) else k for k in keys]
577+
keys = [k.lstrip('/') for k in keys]
578+
527579
if debug:
528-
print("contents = \n", contents)
529-
# Return the list of S3 paths to files
530-
return contents
580+
print("contents = \n", keys)
581+
582+
return keys
583+
584+
585+
# 7 days (SigV4 max) so shard URLs outlast long training runs. Override per
586+
# call or via STABLE_AUDIO_TOOLS_S3_PRESIGN_EXPIRY.
587+
_DEFAULT_PRESIGN_EXPIRY_SECONDS = 7 * 24 * 3600
531588

532589

533590
def get_all_s3_urls(
@@ -540,8 +597,21 @@ def get_all_s3_urls(
540597
# print debugging info -- note: info displayed likely to change at dev's whims
541598
debug=False,
542599
profiles={}, # dictionary of profiles for each item in names, e.g. {'dataset1': 'profile1', 'dataset2': 'profile2'}
600+
presign_expiry_seconds=None, # presigned-URL lifetime; None -> env var or default
543601
):
544602
"get urls of shards (tar files) for multiple datasets in one s3 bucket"
603+
if presign_expiry_seconds is None:
604+
raw = os.environ.get("STABLE_AUDIO_TOOLS_S3_PRESIGN_EXPIRY")
605+
if raw is None:
606+
presign_expiry_seconds = _DEFAULT_PRESIGN_EXPIRY_SECONDS
607+
else:
608+
try:
609+
presign_expiry_seconds = int(raw)
610+
except ValueError:
611+
raise ValueError(
612+
"STABLE_AUDIO_TOOLS_S3_PRESIGN_EXPIRY must be an integer number "
613+
f"of seconds, got: {raw!r}"
614+
)
545615
urls = []
546616
for name in names:
547617
# If s3_url_prefix is not specified, assume the full S3 path is included in each element of the names list
@@ -560,22 +630,30 @@ def get_all_s3_urls(
560630
profile = profiles.get(name, None)
561631
tar_list = get_s3_contents(
562632
subset_str, s3_url_prefix=None, recursive=recursive, filter=filter_str, debug=debug, profile=profile)
633+
# Reuse the cached S3 client (shared with get_s3_contents) for presigning.
634+
s3_client = _get_s3_client(profile=profile)
563635
for tar in tar_list:
564-
# Escape spaces and parentheses in the tar filename for use in the shell command
565-
tar = tar.replace(" ", "\ ").replace(
566-
"(", "\(").replace(")", "\)")
567-
# Construct the S3 path to the current tar file
568-
s3_path = posixpath.join(name, subset, tar) + " -"
569-
# Construct the AWS CLI command to download the current tar file
636+
# Construct the full s3:// URL for the current tar file.
570637
if s3_url_prefix is None:
571-
request_str = f"pipe:aws s3 --cli-connect-timeout 0 cp {s3_path}"
638+
full_s3_url = posixpath.join(name, subset, tar)
572639
else:
573-
request_str = f"pipe:aws s3 --cli-connect-timeout 0 cp {posixpath.join(s3_url_prefix, s3_path)}"
574-
if profiles.get(name):
575-
request_str += f" --profile {profiles.get(name)}"
640+
full_s3_url = posixpath.join(s3_url_prefix, name, subset, tar)
641+
642+
bucket, key = _parse_s3_url(full_s3_url)
643+
644+
# Presigned GET URL works against AWS and any S3-compatible
645+
# endpoint when AWS_ENDPOINT_URL is set. Expiry is configurable
646+
# so long training runs do not outlive their shard URLs.
647+
presigned = s3_client.generate_presigned_url(
648+
"get_object",
649+
Params={"Bucket": bucket, "Key": key},
650+
ExpiresIn=presign_expiry_seconds,
651+
)
652+
request_str = f'pipe:curl -fsSL "{presigned}"'
576653
if debug:
577-
print("request_str = ", request_str)
578-
# Add the constructed URL to the list of URLs
654+
# Strip the signed query string so signatures are not logged.
655+
redacted = presigned.split("?", 1)[0]
656+
print(f'request_str = pipe:curl -fsSL "{redacted}?<redacted>"')
579657
urls.append(request_str)
580658
return urls
581659

0 commit comments

Comments
 (0)