Skip to content

fetch

fetch

Hash-verified, mirrored, offline-first file fetching.

The fetch order for every file is:

  1. The file already exists under the data root with a matching checksum.
  2. The file exists in the read-only shared cache (FWL_DATA_CACHE) with a matching checksum and is copied into the data root atomically.
  3. Offline mode is active: raise OfflineDataError.
  4. Download from each mirror in turn (Zenodo first, then Dataverse), verify the checksum, and move the file into place atomically.

Downloads are performed by pooch, which resolves doi: URLs for Zenodo and Dataverse records natively. Files are never written directly to their final location: every write lands in a staging directory on the same filesystem (<data_root>/.fwl-io-staging) and is moved into place with os.replace, so a crashed download can never leave a corrupt file that later runs would trust. Stale staging entries are pruned opportunistically.

Mirror failures (network, checksum) and local placement failures (read-only tree, full disk) are reported as distinct errors: a permission problem on the data root is never disguised as a download problem.

A transient transport failure (a read or connect timeout, a dropped or reset connection, or a 429 or transient 5xx server response) is retried: every mirror is tried once per round, and the whole set is retried on a short backoff schedule when a round ends with no success and at least one transient failure. A round whose failures are all permanent (a 404 or a checksum mismatch) stops the retries at once. Every request carries an explicit connect and read timeout, so a stalled mirror socket fails in bounded time instead of hanging the fetch.

Concurrency: a burst of processes (for example many PROTEUS instances started together) that all miss the same file would otherwise each download it, hammering the mirrors and risking rate-limiting for the whole collaboration. :meth:Fetcher.fetch serialises fetchers of the same file with a per-target inter-process lock and re-checks the target under it, so only the first process downloads and the rest reuse the completed file; distinct files still fetch in parallel. The lock lives on the shared data root and is coherent across nodes where the filesystem supports it (verified on Kapteyn NFS and Hรกbrรณk Lustre). It is best-effort: if the filesystem has no usable lock manager or a holder stalls past lock_timeout, waiters log a warning and fetch unguarded rather than failing or blocking the batch.

A dataset pinned to a Zenodo version DOI resolves into a per-version directory <subdir>/r<record-id> so successive deposits coexist, and fetch_all writes a .fwl-io.json stamp (DOI, checksums, fetch date) there so a completed dataset directory or shared cache is self-describing.

A dataset given extract="tar" or "zip" ships as a single archive: the archive is downloaded and checksum-verified like any file, then extracted (safely, rejecting members that escape the directory) into the version directory, with the tree moved into place atomically. The archive is not kept.

DownloadError

Bases: RuntimeError

A file could not be obtained from any configured mirror.

Fetcher(subdir, registry, zenodo=None, dataverse=None, base_urls=None, data_root=None, progress=False, extract=None, lock_timeout=_LOCK_TIMEOUT_S)

Fetches the files of one dataset below the data root.

A dataset pinned to a Zenodo version DOI lands in a per-version directory <data_root>/<subdir>/r<record-id>, so an updated deposit is placed beside its predecessor rather than overwriting it, and :meth:fetch_all writes a .fwl-io.json provenance stamp there so the completed directory is self-describing. A source given only as base_urls (no Zenodo pin) keeps the bare <data_root>/<subdir>.

Source code in src/fwl_io/fetch.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
def __init__(
    self,
    subdir: str,
    registry: dict[str, str],
    zenodo: str | None = None,
    dataverse: str | None = None,
    base_urls: list[str] | None = None,
    data_root: str | Path | None = None,
    progress: bool = False,
    extract: str | None = None,
    lock_timeout: float = _LOCK_TIMEOUT_S,
):
    if not registry:
        raise ValueError('empty registry: run "fwl-io sync" for this dataset first')
    for name in registry:
        validate_entry_name(name)
    if extract is not None:
        if extract not in ARCHIVE_KINDS:
            raise ValueError(
                f'unknown extract kind {extract!r}; expected one of {ARCHIVE_KINDS}'
            )
        if len(registry) != 1:
            raise ValueError(
                f'an archive dataset lists exactly one archive file in its registry, '
                f'got {len(registry)}: {sorted(registry)}'
            )
        if not zenodo:
            raise ValueError(
                'an archive dataset requires a Zenodo version DOI: extraction needs a '
                'version directory to stamp and to detect a deleted member on refetch'
            )
    # A DOI is used both as a mirror URL and as the version-directory key,
    # so surrounding whitespace is trimmed once here rather than reaching a
    # request URL through the string interpolation below.
    zenodo = zenodo.strip() if zenodo else zenodo
    dataverse = dataverse.strip() if dataverse else dataverse
    self.subdir = subdir
    self.zenodo = zenodo
    self.registry = dict(registry)
    self.progress = progress
    self.extract = extract
    self.lock_timeout = lock_timeout
    # A dataset pinned to a Zenodo version DOI resolves into a per-version
    # directory r<record-id> below its subdir, so an updated deposit lands
    # beside its predecessor instead of overwriting it. Sources without a
    # Zenodo pin (direct base_urls) keep the bare subdir.
    self.record_id = zenodo_record_id(zenodo) if zenodo else None
    self.version_dir = f'r{self.record_id}' if self.record_id else None
    self.rel_dir = f'{subdir}/{self.version_dir}' if self.version_dir else subdir
    self.data_root = resolve_data_root(data_root)
    self.target_dir = self.data_root / self.rel_dir
    if not self.target_dir.resolve().is_relative_to(self.data_root.resolve()):
        raise ValueError(
            f'subdir {subdir!r} escapes the data root {self.data_root}; '
            f'it must be a relative path without ".." components'
        )
    self._sources: dict[str, str] = {}
    self._archive_members: list[str] = []

    self.mirrors: list[str] = []
    if base_urls:
        self.mirrors.extend(url if url.endswith('/') else url + '/' for url in base_urls)
    if zenodo:
        self.mirrors.append(f'doi:{zenodo}/')
    if dataverse:
        self.mirrors.append(f'doi:{dataverse}/')
    if not self.mirrors:
        raise ValueError('no data source: provide zenodo, dataverse, or base_urls')

fetch(fname, offline=None)

Return a verified local path for fname, downloading if needed.

Source code in src/fwl_io/fetch.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def fetch(self, fname: str, offline: bool | None = None) -> Path:
    """Return a verified local path for ``fname``, downloading if needed."""
    if fname not in self.registry:
        raise KeyError(f'{fname!r} is not in the registry for {self.subdir!r}')
    known_hash = self.registry[fname]
    target = self.target_dir / fname

    # Fast path: an already-present, valid file needs no work and no lock,
    # so the common case (data already on disk) pays nothing for the guard.
    if target.is_file() and _hash_matches(target, known_hash):
        self._sources.setdefault(fname, 'local')
        return target

    # Serialise concurrent fetchers of the *same* file across processes: a
    # burst of instances that all miss it would otherwise each hit the
    # mirrors at once and risk getting the collaboration rate-limited. The
    # lock is per-target, so unrelated files still fetch in parallel.
    # Double-checked: re-test the target under the lock, because another
    # process may have completed the download while we waited.
    with self._fetch_lock(fname, target):
        if target.is_file() and _hash_matches(target, known_hash):
            self._sources.setdefault(fname, 'local')
            return target
        if target.is_file():
            log.warning('checksum mismatch for %s; refetching', target)

        cached = self._fetch_from_cache(fname, known_hash, target)
        if cached is not None:
            return cached

        if is_offline() if offline is None else offline:
            raise OfflineDataError(
                f'{target} is missing or invalid and offline mode is active; '
                f'populate the data tree at {self.data_root} or unset FWL_IO_OFFLINE'
            )
        return self._download(fname, known_hash, target)

fetch_all(offline=None)

Fetch every registry file, then stamp the version directory.

The stamp is written here (the whole-dataset operation), not by an individual :meth:fetch, so a version directory populated one file at a time is not stamped until a fetch_all completes it. A stamp-write failure never fails the fetch: the data is already in place. An archive dataset is downloaded, verified, and extracted as one operation.

Source code in src/fwl_io/fetch.py
457
458
459
460
461
462
463
464
465
466
467
468
469
470
def fetch_all(self, offline: bool | None = None) -> list[Path]:
    """Fetch every registry file, then stamp the version directory.

    The stamp is written here (the whole-dataset operation), not by an
    individual :meth:`fetch`, so a version directory populated one file at
    a time is not stamped until a ``fetch_all`` completes it. A stamp-write
    failure never fails the fetch: the data is already in place. An archive
    dataset is downloaded, verified, and extracted as one operation.
    """
    if self.extract is not None:
        return self._fetch_archive(offline=offline)
    paths = [self.fetch(name, offline=offline) for name in sorted(self.registry)]
    self._write_stamp()
    return paths

file_matches(fname)

True when the local file for fname matches its registry digest.

Reads the file; it does not fetch, and it does not check that the file exists first, so a caller wanting to tell an absent file from a corrupt one tests for presence itself. An unreadable file raises OSError rather than reporting a mismatch, since a permission problem on the tree is a different fault from wrong contents.

Raises:

Type Description
KeyError

fname is not declared in this dataset's registry, so there is no digest to compare it against.

OSError

The file could not be read, whether because it is absent or because the tree denies access to it.

Source code in src/fwl_io/fetch.py
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
def file_matches(self, fname: str) -> bool:
    """True when the local file for ``fname`` matches its registry digest.

    Reads the file; it does not fetch, and it does not check that the file
    exists first, so a caller wanting to tell an absent file from a corrupt
    one tests for presence itself. An unreadable file raises ``OSError``
    rather than reporting a mismatch, since a permission problem on the
    tree is a different fault from wrong contents.

    Raises
    ------
    KeyError
        ``fname`` is not declared in this dataset's registry, so there is
        no digest to compare it against.
    OSError
        The file could not be read, whether because it is absent or
        because the tree denies access to it.
    """
    if fname not in self.registry:
        raise KeyError(f'{fname!r} is not in the registry for {self.subdir!r}')
    return _hash_matches(self.target_dir / fname, self.registry[fname])

provenance()

Return (file, source, checksum) records for run-provenance manifests.

source is the actual origin of files resolved by this Fetcher instance (local, cache:<path>, or the mirror that served the download). Files not yet fetched in this session are marked with a declared: prefix on the primary mirror, since their true origin is unknown.

Source code in src/fwl_io/fetch.py
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
def provenance(self) -> list[dict[str, str]]:
    """Return (file, source, checksum) records for run-provenance manifests.

    ``source`` is the actual origin of files resolved by this Fetcher
    instance (``local``, ``cache:<path>``, or the mirror that served the
    download). Files not yet fetched in this session are marked with a
    ``declared:`` prefix on the primary mirror, since their true origin
    is unknown.
    """
    return [
        {
            'file': f'{self.rel_dir}/{name}',
            'source': self._sources.get(name, f'declared:{self.mirrors[0]}'),
            'checksum': digest,
        }
        for name, digest in sorted(self.registry.items())
    ]

recorded_members()

Return the extracted members this dataset's stamp records.

None when there is no stamp describing this dataset's tree, which says the tree is not there to be examined rather than that it is empty. Callers must keep those apart: an empty list would read as a complete tree of no files.

A stamp qualifies only when it describes this deposit and this archive kind. One left by a different fetch of the same subdirectory, a plain fetch or another version, does not describe this tree.

Members that would resolve outside the dataset directory are dropped. A stamp is a file on disk like any other and can be edited or replaced, so a name in it is treated with the same suspicion as a name inside an archive rather than joined onto the tree unchecked.

Source code in src/fwl_io/fetch.py
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
def recorded_members(self) -> list[str] | None:
    """Return the extracted members this dataset's stamp records.

    ``None`` when there is no stamp describing this dataset's tree, which
    says the tree is not there to be examined rather than that it is empty.
    Callers must keep those apart: an empty list would read as a complete
    tree of no files.

    A stamp qualifies only when it describes this deposit and this archive
    kind. One left by a different fetch of the same subdirectory, a plain
    fetch or another version, does not describe this tree.

    Members that would resolve outside the dataset directory are dropped.
    A stamp is a file on disk like any other and can be edited or replaced,
    so a name in it is treated with the same suspicion as a name inside an
    archive rather than joined onto the tree unchecked.
    """
    return self._stamp_members(self.target_dir)

OfflineDataError

Bases: RuntimeError

A required file is unavailable locally while offline mode is active.

create_fetcher(subdir, zenodo=None, dataverse=None, registry=None, base_urls=None, data_root=None, progress=False, extract=None, lock_timeout=_LOCK_TIMEOUT_S)

Create a :class:Fetcher for one dataset.

Parameters:

Name Type Description Default
subdir str

Location of the dataset below the data root. When a Zenodo pin is given the files land in a version directory <subdir>/r<record-id> below it.

required
zenodo str | None

Version DOIs of the primary record and its mirror.

None
dataverse str | None

Version DOIs of the primary record and its mirror.

None
registry dict | str | Path | None

Name-to-hash mapping, or the path of a committed registry file.

None
base_urls list[str] | None

Direct base URLs, tried before the DOI mirrors (used in tests and for non-DOI sources).

None
data_root str | Path | None

Override for the data root; defaults to the resolved FWL_DATA tree.

None
progress bool

Show a download progress bar (requires tqdm; useful for large files).

False
extract str | None

When set ("tar" or "zip"), the single registry entry is a downloadable archive; it is verified, then its members are extracted into the dataset directory and the archive itself is discarded.

None
lock_timeout float

Seconds a fetcher waits for the per-target download lock before giving up and fetching unguarded (default five minutes). The lock only suppresses duplicate concurrent downloads; a waiter that times out (or a filesystem without a working lock manager) falls back to its own fetch rather than blocking or failing.

_LOCK_TIMEOUT_S
Source code in src/fwl_io/fetch.py
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
def create_fetcher(
    subdir: str,
    zenodo: str | None = None,
    dataverse: str | None = None,
    registry: dict[str, str] | str | Path | None = None,
    base_urls: list[str] | None = None,
    data_root: str | Path | None = None,
    progress: bool = False,
    extract: str | None = None,
    lock_timeout: float = _LOCK_TIMEOUT_S,
) -> Fetcher:
    """Create a :class:`Fetcher` for one dataset.

    Parameters
    ----------
    subdir : str
        Location of the dataset below the data root. When a Zenodo pin is
        given the files land in a version directory ``<subdir>/r<record-id>``
        below it.
    zenodo, dataverse : str | None
        Version DOIs of the primary record and its mirror.
    registry : dict | str | Path | None
        Name-to-hash mapping, or the path of a committed registry file.
    base_urls : list[str] | None
        Direct base URLs, tried before the DOI mirrors (used in tests and for
        non-DOI sources).
    data_root : str | Path | None
        Override for the data root; defaults to the resolved FWL_DATA tree.
    progress : bool
        Show a download progress bar (requires tqdm; useful for large files).
    extract : str | None
        When set (``"tar"`` or ``"zip"``), the single registry entry is a
        downloadable archive; it is verified, then its members are extracted
        into the dataset directory and the archive itself is discarded.
    lock_timeout : float
        Seconds a fetcher waits for the per-target download lock before giving
        up and fetching unguarded (default five minutes). The lock only
        suppresses duplicate concurrent downloads; a waiter that times out (or
        a filesystem without a working lock manager) falls back to its own
        fetch rather than blocking or failing.
    """
    if isinstance(registry, (str, Path)):
        registry = load_registry(registry)
    return Fetcher(
        subdir=subdir,
        registry=registry or {},
        zenodo=zenodo,
        dataverse=dataverse,
        base_urls=base_urls,
        data_root=data_root,
        progress=progress,
        extract=extract,
        lock_timeout=lock_timeout,
    )