11"""Lazy download of large asset files from a public S3 bucket.
22
3- Most asset files (configs, poses, and the simplified default NeuroMechFly meshes)
4- are small enough to ship inside the ``flygym`` package. The high-resolution
5- ``fullsize`` meshes -- especially the FlyBody ``.obj`` meshes, which are an order
6- of magnitude larger than everything else combined -- would bloat the package and
7- the git repository, so they are hosted on an institution-managed S3 bucket and
8- pulled in *the first time they are needed*, similar to how PyTorch downloads
9- pretrained weights.
10-
11- Downloaded files are cached on disk (see :func:`get_cache_root`) so the download
12- happens only once per machine. The bucket is public and served over a standard
13- S3-compatible HTTP endpoint, so plain ``urllib`` is enough -- no extra
14- dependencies (boto3 etc.) are required.
15-
16- The bucket stores each remotely hosted asset directory as a flat, *versioned*
17- sub-prefix of :data:`S3_ROOT_PREFIX`, so future revisions can be uploaded under a
18- new name without disturbing existing releases. Bump the version constants below to
19- point a release at a new version. Example:
20-
21- bucket: flygym_assets/neuromechfly_fullsize_meshes_20260623a/<file>
22- cache: ~/.cache/flygym_assets/neuromechfly_fullsize_meshes_20260623a/<file>
3+ The high-resolution `fullsize` meshes are too large to ship inside the
4+ `flygym` package, so they are hosted on a public S3 bucket and downloaded the
5+ first time they are needed, then cached on disk (see :func:`get_cache_root`).
6+ The bucket is served over plain HTTP(S), so `urllib` is enough -- no boto3.
7+
8+ Each asset directory lives on the bucket as a `<name>.tar` archive plus a
9+ `<name>.checksum` sidecar holding the tar's sha256 hex digest, both generated
10+ by `scripts/dev/make_tar_for_lazy_loaded_assets.sh`. Names are versioned
11+ (e.g. `neuromechfly_fullsize_meshes_20260623a`) so new revisions can be
12+ uploaded without disturbing existing releases; bump the version constants in
13+ the fly model modules to point a release at a new asset set.
2314"""
2415
2516import hashlib
2617import os
27- import shutil
18+ import tarfile
2819import tempfile
2920from pathlib import Path
3021from urllib .parse import quote
3122from urllib .request import urlopen
32- from xml .etree import ElementTree
3323
3424from loguru import logger
3525
36- __all__ = ["get_cache_root" , "lazy_load_asset_dir" , "prefetch_meshes " ]
26+ __all__ = ["get_cache_root" , "lazy_load_asset_dir" , "download_all_assets " ]
3727
3828#: Base HTTP(S) endpoint of the S3-compatible object store.
3929S3_ENDPOINT = "https://datasets.epfl.ch"
4232#: Top-level key prefix within the bucket under which all assets live.
4333S3_ROOT_PREFIX = "flygym_assets"
4434
45-
46- # S3 ListObjectsV2 responses are namespaced; this is the namespace MinIO/S3 use.
47- _S3_XML_NS = {"s3" : "http://s3.amazonaws.com/doc/2006-03-01/" }
35+ #: Read/write files in chunks of this size while streaming a download.
36+ _CHUNK_SIZE = 1024 * 1024
37+ #: How many times to (re)try a download before giving up. A dropped connection
38+ #: yields a truncated tar that fails the checksum; the endpoint is flaky enough
39+ #: that a single such failure shouldn't abort the whole run.
40+ _MAX_ATTEMPTS = 3
41+ #: Per-request timeout (seconds). Bounds how long a stalled connection can hang
42+ #: before it errors out and the attempt is retried, rather than blocking forever.
43+ _TIMEOUT = 30
4844
4945
5046def get_cache_root () -> Path :
51- """Return the directory under which downloaded assets are cached.
52-
53- Resolution order:
54-
55- 1. ``$FLYGYM_ASSET_CACHE_DIR`` if set (useful for CI caching or shared,
56- read-only installs);
57- 2. ``$XDG_CACHE_HOME/flygym_assets`` if ``XDG_CACHE_HOME`` is set;
58- 3. ``~/.cache/flygym_assets`` otherwise.
59-
60- The directory is named ``flygym_assets`` to match the bucket's top-level
61- prefix (:data:`S3_ROOT_PREFIX`).
47+ """Return the directory under which downloaded assets are cached:
48+ `$FLYGYM_ASSET_CACHE_DIR` if set (useful for CI caching), else
49+ `$XDG_CACHE_HOME/flygym_assets`, else `~/.cache/flygym_assets`.
6250 """
6351 env = os .environ .get ("FLYGYM_ASSET_CACHE_DIR" )
6452 if env :
6553 return Path (env ).expanduser ()
66- # Per the XDG Base Directory spec, a relative XDG_CACHE_HOME is invalid and
67- # must be ignored (as is an unset/empty value).
54+ # Per the XDG spec, a relative XDG_CACHE_HOME is invalid and must be ignored.
6855 xdg = os .environ .get ("XDG_CACHE_HOME" )
6956 if xdg and os .path .isabs (xdg ):
7057 return Path (xdg ) / S3_ROOT_PREFIX
@@ -76,150 +63,85 @@ def _object_url(key: str) -> str:
7663 return f"{ S3_ENDPOINT } /{ S3_BUCKET } /{ quote (key )} "
7764
7865
79- def _list_s3_prefix (prefix : str ) -> list [dict ]:
80- """List every object under ``prefix`` via the public ListObjectsV2 API.
81-
82- Returns a list of ``{"key", "size", "etag"}`` dicts. Handles pagination via
83- continuation tokens. The bucket is public, so the request is unsigned.
84- """
85- if not prefix .endswith ("/" ):
86- prefix += "/"
87- objects : list [dict ] = []
88- continuation_token : str | None = None
89- while True :
90- url = f"{ S3_ENDPOINT } /{ S3_BUCKET } ?list-type=2&prefix={ quote (prefix , safe = '' )} "
91- if continuation_token is not None :
92- url += f"&continuation-token={ quote (continuation_token , safe = '' )} "
93- with urlopen (url ) as response :
94- tree = ElementTree .fromstring (response .read ())
95- for contents in tree .findall ("s3:Contents" , _S3_XML_NS ):
96- key = contents .findtext ("s3:Key" , namespaces = _S3_XML_NS )
97- if key is None or key .endswith ("/" ):
98- continue # skip "directory" placeholder keys
99- size = int (contents .findtext ("s3:Size" , default = "0" , namespaces = _S3_XML_NS ))
100- etag = contents .findtext ("s3:ETag" , default = "" , namespaces = _S3_XML_NS )
101- objects .append ({"key" : key , "size" : size , "etag" : etag .strip ('"' )})
102- is_truncated = (
103- tree .findtext ("s3:IsTruncated" , default = "false" , namespaces = _S3_XML_NS )
104- == "true"
105- )
106- if not is_truncated :
107- break
108- continuation_token = tree .findtext (
109- "s3:NextContinuationToken" , namespaces = _S3_XML_NS
110- )
111- if not continuation_token :
112- break
113- return objects
114-
115-
116- def _is_up_to_date (path : Path , size : int , etag : str ) -> bool :
117- """Return True if ``path`` already holds the object described by (size, etag).
118-
119- For non-multipart uploads the S3 ETag is the MD5 hex digest of the content,
120- which we verify. Multipart ETags contain a ``-`` and are not plain MD5, so we
121- fall back to a size check for those.
66+ def _download_tar (name : str , dest : Path ) -> None :
67+ """Download `<name>.tar` to `dest` and verify it against `<name>.checksum`,
68+ retrying on transient network errors and truncated (checksum-mismatched)
69+ downloads.
12270 """
123- if not path .is_file ():
124- return False
125- if path .stat ().st_size != size :
126- return False
127- if etag and "-" not in etag :
128- digest = hashlib .md5 (path .read_bytes ()).hexdigest ()
129- return digest == etag
130- return True
131-
132-
133- def _download_object (key : str , dest : Path , size : int , etag : str ) -> None :
134- """Download a single object to ``dest`` atomically."""
135- dest .parent .mkdir (parents = True , exist_ok = True )
136- fd , tmp_name = tempfile .mkstemp (dir = dest .parent , suffix = ".part" )
137- tmp_path = Path (tmp_name )
138- try :
139- with os .fdopen (fd , "wb" ) as out , urlopen (_object_url (key )) as response :
140- shutil .copyfileobj (response , out )
141- if not _is_up_to_date (tmp_path , size , etag ):
142- raise OSError (
143- f"Downloaded asset failed integrity check: { key } "
144- f"(expected { size } bytes, etag { etag !r} )"
145- )
146- tmp_path .replace (dest )
147- finally :
148- tmp_path .unlink (missing_ok = True )
149-
71+ checksum_url = _object_url (f"{ S3_ROOT_PREFIX } /{ name } .checksum" )
72+ with urlopen (checksum_url , timeout = _TIMEOUT ) as response :
73+ expected = response .read ().decode ().split ()[0 ]
15074
151- def _download_prefix (s3_prefix : str , dest_dir : Path ) -> Path :
152- """Download every object under ``s3_prefix`` into ``dest_dir`` (skipping files
153- that are already present and up to date). Returns ``dest_dir``.
154- """
155- objects = _list_s3_prefix (s3_prefix )
156- if not objects :
157- raise FileNotFoundError (
158- f"No assets found on S3 under prefix '{ s3_prefix } '. The bucket may be "
159- "unreachable or the asset may have been moved."
160- )
161- prefix = s3_prefix if s3_prefix .endswith ("/" ) else s3_prefix + "/"
162- pending = []
163- for obj in objects :
164- rel_key = obj ["key" ][len (prefix ) :]
165- dest = dest_dir / rel_key
166- if not _is_up_to_date (dest , obj ["size" ], obj ["etag" ]):
167- pending .append ((obj , dest ))
168-
169- if pending :
170- total_mb = sum (obj ["size" ] for obj , _ in pending ) / 1e6
171- logger .info (
172- f"Downloading { len (pending )} FlyGym asset file(s) "
173- f"({ total_mb :.1f} MB) from S3 to { dest_dir } (one-time download)..."
174- )
175- for obj , dest in pending :
176- _download_object (obj ["key" ], dest , obj ["size" ], obj ["etag" ])
177- logger .info ("Finished downloading FlyGym assets." )
178- return dest_dir
75+ tar_url = _object_url (f"{ S3_ROOT_PREFIX } /{ name } .tar" )
76+ for attempt in range (1 , _MAX_ATTEMPTS + 1 ):
77+ digest = hashlib .sha256 ()
78+ try :
79+ with (
80+ urlopen (tar_url , timeout = _TIMEOUT ) as response ,
81+ open (dest , "wb" ) as out ,
82+ ):
83+ while chunk := response .read (_CHUNK_SIZE ):
84+ digest .update (chunk )
85+ out .write (chunk )
86+ except OSError as e :
87+ reason = f"download failed ({ e } )"
88+ else :
89+ if digest .hexdigest () == expected :
90+ return
91+ reason = (
92+ f"integrity check failed "
93+ f"(expected sha256 { expected } , got { digest .hexdigest ()} )"
94+ )
95+ dest .unlink (missing_ok = True )
96+ if attempt == _MAX_ATTEMPTS :
97+ raise OSError (f"Could not download { name } .tar: { reason } " )
98+ logger .warning (f"Retrying { name } .tar ({ attempt } /{ _MAX_ATTEMPTS } ): { reason } " )
17999
180100
181101def lazy_load_asset_dir (rel_path : os .PathLike | str ) -> Path :
182102 """Return the absolute local path to a bucket asset directory, downloading it
183103 from S3 on first use.
184104
185105 Args:
186- rel_path: Path of the directory within the bucket, relative to
187- :data:`S3_ROOT_PREFIX` (e.g. ``"neuromechfly_fullsize_meshes_20260623a"``,
188- as defined by each fly model's ``*_FULLSIZE_MESH_DIR`` constant).
189-
190- The directory is cached under :func:`get_cache_root` keyed by ``rel_path``. If
191- the cached copy already exists it is returned as-is (no network access);
192- otherwise the whole directory is downloaded into a temporary location and moved
193- into place atomically, so an interrupted or concurrent download never leaves a
194- partial cache.
195-
196- Raises:
197- FileNotFoundError: If ``rel_path`` does not exist in the bucket.
106+ rel_path: Name of the asset set on the bucket, i.e. the shared stem of
107+ `<rel_path>.tar` and `<rel_path>.checksum` under
108+ :data:`S3_ROOT_PREFIX` (as defined by each fly model's
109+ `*_MESH_DIR` constant).
110+
111+ If the cached copy already exists it is returned as-is (no network access).
112+ Otherwise the tar is downloaded, verified, and extracted inside a temporary
113+ directory that is moved into place atomically, so an interrupted or
114+ concurrent download never leaves a partial cache.
198115 """
199- rel_path = Path (rel_path )
200- cache_dir = get_cache_root () / rel_path
116+ name = Path (rel_path ). as_posix ( )
117+ cache_dir = get_cache_root () / name
201118 if cache_dir .is_dir ():
202119 return cache_dir
203120
121+ logger .info (f"Downloading FlyGym asset '{ name } ' from S3 (one-time download)..." )
204122 cache_dir .parent .mkdir (parents = True , exist_ok = True )
205- staging = Path (tempfile .mkdtemp (dir = cache_dir .parent , suffix = ".partial" ))
206- try :
207- _download_prefix (f"{ S3_ROOT_PREFIX } /{ rel_path .as_posix ()} " , staging )
123+ with tempfile .TemporaryDirectory (
124+ dir = cache_dir .parent , suffix = ".partial"
125+ ) as staging :
126+ staging = Path (staging )
127+ _download_tar (name , staging / "asset.tar" )
128+ extracted = staging / "extracted"
129+ with tarfile .open (staging / "asset.tar" ) as tar :
130+ tar .extractall (extracted , filter = "data" )
208131 try :
209- staging .replace (cache_dir )
132+ extracted .replace (cache_dir )
210133 except OSError :
211134 # Another process finished downloading the same asset while we were
212- # working: os.replace cannot move onto the now- populated directory .
213- # Their copy is equivalent to ours, so use it instead of failing.
135+ # working, so cache_dir is now populated and cannot be replaced .
136+ # Their copy is equivalent to ours: use it instead of failing.
214137 if not cache_dir .is_dir ():
215138 raise
216- finally :
217- shutil .rmtree (staging , ignore_errors = True )
139+ logger .info (f"Finished downloading FlyGym asset '{ name } '." )
218140 return cache_dir
219141
220142
221- def prefetch_meshes () -> list [Path ]:
222- """Eagerly download all remotely hosted meshes into the cache.
143+ def download_all_assets () -> list [Path ]:
144+ """Eagerly download all remotely hosted assets into the cache.
223145
224146 Useful for warming a CI cache or preparing an offline environment. Returns the
225147 list of local directories that now hold the assets.
0 commit comments