Source code for pyavs.remote.store
"""
On-demand fetching from the public AVS S3 bucket.
Mirrors :func:`pyavs.scenes.fetch.fetch_scene_image`'s download pattern: a
plain HTTPS GET (the bucket is public-read, so no credentials or AWS SDK are
needed), written atomically (temp file + rename) so an interrupted download
never leaves a corrupt file behind, and cached locally so repeat calls skip
the network.
v1 only fetches whole objects. Chunk-level HTTP range reads for
content-indexed epoch queries (e.g. "every fixation on a dog, across
subjects") are a separate, larger piece of work — see
``release/remote_dataloader_design.md``, not implemented here.
"""
import time
from pathlib import Path
from typing import Optional, Union
from urllib.parse import quote
import requests
from ..utils.logging import get_logger
logger = get_logger('remote.store')
DEFAULT_BUCKET = 'kietzmannlab-avs'
DEFAULT_REGION = 'us-west-2'
REQUEST_TIMEOUT = 30 # seconds
[docs]
class RemoteFileNotFoundError(FileNotFoundError):
"""Raised when the bucket has no object at the requested key."""
def _format_size(n_bytes: int) -> str:
"""Human-readable byte count, e.g. ``'1.97 GB'``."""
size = float(n_bytes)
for unit in ('B', 'KB', 'MB', 'GB'):
if size < 1000 or unit == 'GB':
return f"{size:.0f} {unit}" if unit == 'B' else f"{size:.2f} {unit}"
size /= 1000
return f"{size:.2f} GB" # unreachable, keeps linters happy
[docs]
class S3Store:
"""
Fetches objects from the public AVS S3 bucket, caching them locally.
Parameters
----------
cache_root : str or Path, optional
Local directory to cache fetched objects under, mirroring the
release tree's relative layout exactly (so a :class:`~pyavs.layout.Layout`
pointed at ``cache_root`` resolves to the same paths). Defaults to
``~/.cache/pyavs/<bucket>``.
bucket : str, optional
S3 bucket name (default: the public AVS release bucket).
region : str, optional
Bucket region (default: ``'us-west-2'``).
timeout : int, optional
HTTP request timeout in seconds (default: 30).
verbose : bool, optional
Log size/time/cache-location feedback for each fetch (default:
True). Set False to fetch silently.
"""
[docs]
def __init__(self, cache_root: Optional[Union[str, Path]] = None,
bucket: str = DEFAULT_BUCKET, region: str = DEFAULT_REGION,
timeout: int = REQUEST_TIMEOUT, verbose: bool = True):
self.bucket = bucket
self.region = region
self.timeout = timeout
self.verbose = verbose
self.cache_root = (Path(cache_root) if cache_root is not None
else Path.home() / '.cache' / 'pyavs' / bucket)
def __repr__(self) -> str:
return f"S3Store(bucket={self.bucket!r}, cache_root={str(self.cache_root)!r})"
[docs]
def url_for(self, dst: str) -> str:
"""Public HTTPS URL for a release-relative key, e.g. ``'sub-01/ses-01/meg/as01a01.fif'``."""
return f"https://{self.bucket}.s3.{self.region}.amazonaws.com/{quote(dst)}"
[docs]
def fetch(self, dst: str, force: bool = False) -> Path:
"""
Fetch one object into the local cache, returning its path.
Parameters
----------
dst : str
Release-relative key, identical to ``manifest.tsv``'s ``dst``
column (e.g. ``'derivatives/pyavs/sub-01/ses-01/epochs/sub-01_ses-01_task-avs_fixation_scene_epochs.h5'``).
force : bool, optional
Re-download even if a cached copy of the expected size already
exists (default: False).
Returns
-------
Path
Local cached path.
Raises
------
RemoteFileNotFoundError
If the bucket has no object at ``dst``.
"""
dest_path = self.cache_root / dst
url = self.url_for(dst)
response = requests.get(url, timeout=self.timeout, stream=True)
if response.status_code == 404:
response.close()
raise RemoteFileNotFoundError(f"No object at {url}")
response.raise_for_status()
expected_size = int(response.headers['Content-Length'])
if not force and dest_path.exists() and dest_path.stat().st_size == expected_size:
response.close()
if self.verbose:
logger.info(f"Cached: {dst} ({_format_size(expected_size)}) at {dest_path}")
return dest_path
if self.verbose:
logger.info(f"Downloading {dst} ({_format_size(expected_size)}) from s3://{self.bucket}...")
start = time.monotonic()
dest_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = dest_path.with_name(dest_path.name + '.tmp')
with open(tmp_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=1 << 20):
f.write(chunk)
tmp_path.replace(dest_path)
elapsed = time.monotonic() - start
if self.verbose:
speed = expected_size / 1e6 / elapsed if elapsed > 0 else float('inf')
logger.info(f"Downloaded {dst} ({_format_size(expected_size)}) in {elapsed:.1f}s "
f"({speed:.1f} MB/s) -> {dest_path}")
return dest_path