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 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.

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)

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
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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,
):
    if not registry:
        raise ValueError('empty registry: run "fwl-io sync" for this dataset first')
    for name in registry:
        validate_entry_name(name)
    self.subdir = subdir
    self.zenodo = zenodo
    self.registry = dict(registry)
    self.progress = progress
    # 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.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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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

    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.

Source code in src/fwl_io/fetch.py
228
229
230
231
232
233
234
235
236
237
238
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.
    """
    paths = [self.fetch(name, offline=offline) for name in sorted(self.registry)]
    self._write_stamp()
    return paths

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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
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())
    ]

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)

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
Source code in src/fwl_io/fetch.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
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,
) -> 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).
    """
    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,
    )