-
Notifications
You must be signed in to change notification settings - Fork 31
Source to access data from Zenodo based on record ID or DOI #933
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
chpolste
wants to merge
8
commits into
develop
Choose a base branch
from
feature/zenodo-source
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
3838ed1
Add source to fetch data from Zenodo based on record ID or DOI
chpolste b22f7c5
Add support for list of files and improve error handling
chpolste 50ecd73
Improve: skip DOI resolution, fix access for concept IDs
chpolste f74f651
Add draft of test suite (WIP)
chpolste f57f5db
Finalise test suite
chpolste b3b7e8b
Add entry in Data sources concepts page
chpolste f22c491
Add from_source overload
chpolste 035c8c6
Fixes
chpolste File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ dependencies = [ | |
| "pandas", | ||
| "pdbufr>=0.11", | ||
| "pyyaml", | ||
| "requests", | ||
| "tqdm>=4.63", | ||
| "xarray>=0.19" | ||
| ] | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| # (C) Copyright 2026- ECMWF and individual contributors. | ||
|
|
||
| # This software is licensed under the terms of the Apache Licence Version 2.0 | ||
| # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. | ||
| # In applying this licence, ECMWF does not waive the privileges and immunities | ||
| # granted to it by virtue of its status as an intergovernmental organisation nor | ||
| # does it submit to any jurisdiction. | ||
|
|
||
| import fnmatch | ||
| import logging | ||
| import re | ||
|
|
||
| import requests | ||
|
|
||
| from earthkit.data.core.config import CONFIG | ||
| from earthkit.data.sources import Source | ||
| from earthkit.data.sources.multi_url import MultiUrl | ||
|
|
||
| LOG = logging.getLogger(__name__) | ||
|
|
||
| _DOI_PATTERN = re.compile( | ||
| r"^(?:doi:\s*|(?:https?:\/\/)?(?:dx\.)?doi\.org\/)?10\.5281/zenodo\.(\d+)\/?$", | ||
| flags=re.IGNORECASE, | ||
| ) | ||
| _URL_PATTERN = re.compile(r"^(?:https?:\/\/)?zenodo\.org\/records?\/(\d+)\/?(?:\?.*)?$") | ||
|
Copilot marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def _get_record_files(record_id): | ||
| timeout = CONFIG.get("url-download-timeout") | ||
|
|
||
| api_url = f"https://zenodo.org/api/records/{record_id}" | ||
| LOG.debug(f"Fetching file list for record {record_id} from {api_url}") | ||
| try: | ||
| r = requests.get(api_url, timeout=timeout) | ||
| r.raise_for_status() | ||
| except requests.ConnectionError as e: | ||
| raise RuntimeError("could not connect to zenodo.org") from e | ||
| except requests.Timeout as e: | ||
| raise RuntimeError(f"request to zenodo.org timed out after {timeout}s.") from e | ||
| except requests.HTTPError as e: | ||
| raise RuntimeError(f"Zenodo API returned HTTP {r.status_code}") from e | ||
|
|
||
| try: | ||
| data = r.json() | ||
| except ValueError as e: | ||
| raise RuntimeError("failed to parse Zenodo API response") from e | ||
|
|
||
| if "files" not in data or not data["files"]: | ||
| raise RuntimeError(f"Record {record_id} has no accessible files. The record may be restricted or embargoed.") | ||
|
chpolste marked this conversation as resolved.
Outdated
|
||
|
|
||
| try: | ||
| # URLs from API response, works for record and concept IDs | ||
| file_urls = {f["key"]: f["links"]["self"] for f in data["files"]} | ||
| except (KeyError, TypeError) as e: | ||
| raise RuntimeError(f"unexpected file entry in the Zenodo API response for record {record_id}") from e | ||
|
|
||
| LOG.debug(f"Record {record_id} contains {len(file_urls)} file(s): {list(file_urls)!r}") | ||
| return file_urls | ||
|
|
||
|
|
||
| class Zenodo(Source): | ||
|
chpolste marked this conversation as resolved.
|
||
| """Source for downloading files from Zenodo records. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| identifier : int | str | ||
| Record ID, Zenodo URL or DOI. A DOI may also be given as a doi.org URL. | ||
| only : str | Sequence[str] | None, optional | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe |
||
| File selection with a glob string or an explicit list of file names. | ||
| By default, all files are selected. | ||
| **kwargs | ||
| Additional keyword arguments forwarded to the URL source. | ||
| """ | ||
|
|
||
| def __init__(self, identifier, only=None, **kwargs): | ||
| super().__init__() | ||
| self._kwargs = kwargs | ||
|
|
||
| if isinstance(identifier, str): | ||
| identifier = identifier.strip() | ||
|
|
||
| # A Zenodo DOI is 10.5281/zenodo.<record ID>, so no lookup via doi.org is needed. | ||
| # For a concept DOI this is the concept record's ID, which the API redirects to the | ||
| # latest version, and the file URLs then refer to that version. | ||
| if isinstance(identifier, str) and (match := _DOI_PATTERN.match(identifier)): | ||
| self.record_id = int(match.group(1)) | ||
| elif isinstance(identifier, int): | ||
| self.record_id = identifier | ||
| elif isinstance(identifier, str) and (match := _URL_PATTERN.match(identifier)): | ||
| self.record_id = int(match.group(1)) | ||
| elif isinstance(identifier, str) and identifier.isnumeric(): | ||
| self.record_id = int(identifier) | ||
| else: | ||
| raise ValueError(f"unable to determine record ID from identifier: {identifier!r}") | ||
|
|
||
| LOG.info(f"Zenodo record ID: {self.record_id}") | ||
|
|
||
| # Fetch file metadata from the Zenodo API | ||
| record_files = _get_record_files(self.record_id) | ||
|
|
||
| # No filenames specified -> select all | ||
| if only is None: | ||
| self._file_urls = record_files | ||
| # Match filenames with provided pattern | ||
| elif isinstance(only, str): | ||
| matched = fnmatch.filter(record_files.keys(), only) | ||
| if not matched: | ||
| raise ValueError(f"no files in record {self.record_id} match the pattern: {only!r}") | ||
| self._file_urls = {name: record_files[name] for name in matched} | ||
| # Select filenames based on provided list | ||
| else: | ||
| only = list(dict.fromkeys(only)) # deduplicate while preserving order | ||
| if not only: | ||
| raise ValueError(f"no files selected from record {self.record_id}") | ||
| self._file_urls = {name: record_files[name] for name in only if name in record_files} | ||
| if len(self._file_urls) != len(only): | ||
| missing = ", ".join(repr(name) for name in only if name not in record_files) | ||
| raise ValueError(f"file(s) not found in record {self.record_id}: " + missing) | ||
|
|
||
| selected = ", ".join(self._file_urls.keys()) | ||
| LOG.info(f"Selected {len(self._file_urls)} file(s) from record {self.record_id}: {selected}") | ||
|
|
||
| def mutate(self): | ||
| urls = list(self._file_urls.values()) | ||
| return MultiUrl(urls, **self._kwargs) | ||
|
|
||
|
|
||
| source = Zenodo | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.