11import importlib
2+ from importlib .metadata import PackageNotFoundError , version
3+ from functools import lru_cache
24import numpy as np
35import io
46import json
57import os
68import dill
79import posixpath
810import random
9- import re
10- import subprocess
1111import time
1212import torch
1313import 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+
486538def 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
533590def 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