Repository navigation
feat!(prepro): validate user-submitted external accessions #7413
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
Changes from all commits
d493f9e
77d16b5
fae7729
316586a
d99e622
08be0e4
7fe9826
e1226d1
966febd
44824fb
0cf3a92
5a53da1
1584358
67f4020
442e77a
8a974a0
e5df686
c569c8a
12422c5
9b12909
3c4a6a6
2fdba41
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,12 @@ | ||
| import io | ||
| import logging | ||
| import re | ||
| import urllib.parse | ||
| import xml.etree.ElementTree as ET | ||
| from collections import OrderedDict | ||
| from dataclasses import dataclass | ||
| from enum import StrEnum | ||
| from http import HTTPStatus | ||
|
|
||
| import requests | ||
| from pydantic import BaseModel, Field, ValidationError | ||
|
|
@@ -17,6 +22,7 @@ | |
| ProcessingAnnotation, | ||
| RawProcessingResult, | ||
| _internal_error_message, | ||
| processing_error, | ||
| raw_internal_error, | ||
| ) | ||
|
|
||
|
|
@@ -210,17 +216,6 @@ def get_common_name(self, tax_id: str) -> RawProcessingResult: | |
| return RawProcessingResult(datum=common_name) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class ExternalServices: | ||
| """External services available to processing functions. | ||
|
|
||
| Kept separate from `ProcessingContext` since these don't vary per accession, unlike | ||
| `ProcessingContext`'s fields. | ||
| """ | ||
|
|
||
| taxonomy_service: TaxonomyService | ||
|
|
||
|
|
||
| FileName = str | ||
|
|
||
|
|
||
|
|
@@ -316,3 +311,111 @@ def _annotation( | |
| if not internal_error: | ||
| return ProcessingAnnotation([source], [source], message) | ||
| return ProcessingAnnotation([source], [source], _internal_error_message(message)) | ||
|
|
||
|
|
||
| # Successful ENA responses are small XML documents (a few KB). Cache hits refresh an entry's | ||
| # position; a bioproject shared across a batch stays cached while unique biosamples and runs | ||
| # cycle through. | ||
| ena_cache = RequestCache(max_size=16, retries=1) | ||
|
|
||
|
|
||
| class EnaAccessionType(StrEnum): | ||
| BIOPROJECT = "bioproject" | ||
| BIOSAMPLE = "biosample" | ||
| RAW_READS = "raw_reads" | ||
|
|
||
|
|
||
| # Also guards against comma-separated lists, which the ENA browser API | ||
| # would otherwise resolve to multiple records. | ||
| # (see https://ena-docs.readthedocs.io/en/latest/submit/general-guide/accessions.html) | ||
| ACCESSION_PATTERNS: dict[EnaAccessionType, re.Pattern[str]] = { | ||
| EnaAccessionType.BIOPROJECT: re.compile(r"PRJ[EDN][A-Z][0-9]+"), | ||
|
anna-parker marked this conversation as resolved.
Resolved
|
||
| EnaAccessionType.BIOSAMPLE: re.compile(r"SAM[EDN][A-Z]?[0-9]+"), | ||
| EnaAccessionType.RAW_READS: re.compile(r"[EDS]RR[0-9]{6,}"), | ||
|
Contributor
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. ah crap sorry I only saw this now, we should change this to what we have configured as the regex for PPX:
Contributor
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. potentially ending in S is not actually possible
Contributor
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. itis possible for things ingested from the INSDC |
||
| } | ||
|
|
||
|
|
||
| class ENAVisibilityChecker: | ||
| """Used to check ENA visibility | ||
|
|
||
| Adapted from the ENAVisibilityChecker in | ||
| ena-submission/src/ena_deposition/check_external_visibility.py. | ||
| If anything changes in that class, check whether the update needs to | ||
| be applied here as well. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| timeout_seconds: int = 10, | ||
| ): | ||
| self.timeout_seconds = timeout_seconds | ||
|
|
||
| def check_visibility(self, accession: str, accession_type_arg: str) -> RawProcessingResult: | ||
| try: | ||
| accession_type = EnaAccessionType(accession_type_arg) | ||
| except ValueError: | ||
| return raw_internal_error( | ||
| f"Invalid accession_type '{accession_type_arg}', expected one of " | ||
| f"{[t.value for t in EnaAccessionType]}." | ||
| ) | ||
| accession = accession.strip().upper() | ||
| pattern = ACCESSION_PATTERNS[accession_type] | ||
| if not pattern.fullmatch(accession): | ||
| return processing_error( | ||
| f"'{accession}' is not a valid {accession_type.replace('_', ' ')} accession, " | ||
| f"expected a value matching '{pattern.pattern}'." | ||
| ) | ||
| try: | ||
| response = ena_cache.get_or_fetch( | ||
|
maverbiest marked this conversation as resolved.
Resolved
|
||
| f"https://www.ebi.ac.uk/ena/browser/api/xml/{accession}", | ||
| timeout=self.timeout_seconds, | ||
| ) | ||
| except requests.RequestException as e: | ||
| logger.warning(f"Failed to check ENA visibility of '{accession}': {e}") | ||
| response = None | ||
| if response is None or response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: | ||
| return RawProcessingResult( | ||
| datum=accession, | ||
| warnings=[ | ||
| f"Unable to check visibility of '{accession}': could not reach ENA. Please " | ||
| f"verify yourself that '{accession}' is public before proceeding or we will " | ||
| "not be able to propagate your submission to the INSDC." | ||
| ], | ||
| ) | ||
| if response.status_code != HTTPStatus.OK: | ||
| return RawProcessingResult( | ||
| datum=accession, | ||
| warnings=[ | ||
| f"Accession '{accession}' is not visible on ENA. Please make '{accession}' " | ||
| "public before proceeding or we will not be able to propagate your " | ||
| "submission to the INSDC." | ||
| ], | ||
| ) | ||
| if accession_type == EnaAccessionType.BIOPROJECT and self._is_umbrella_project( | ||
| response.content | ||
| ): | ||
| return processing_error( | ||
| f"bioproject '{accession}' is an umbrella project. " | ||
| "Please provide the accession of a submission project instead." | ||
| ) | ||
| return RawProcessingResult(datum=accession) | ||
|
|
||
| def _is_umbrella_project(self, xml_bytes) -> bool: | ||
| for _, elem in ET.iterparse(io.BytesIO(xml_bytes), events=("start",)): # noqa: S314 | ||
|
maverbiest marked this conversation as resolved.
Resolved
|
||
| if elem.tag == "UMBRELLA_PROJECT": | ||
| return True | ||
| if elem.tag == "SUBMISSION_PROJECT": | ||
| return False | ||
| return False | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class ExternalServices: | ||
| """External services available to processing functions. | ||
|
|
||
| Kept separate from `ProcessingContext` since these don't vary per accession, unlike | ||
| `ProcessingContext`'s fields. | ||
| """ | ||
|
|
||
| taxonomy_service: TaxonomyService | ||
| ena_visibility_checker: ENAVisibilityChecker | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1297,6 +1297,27 @@ def common_name_from_id( # noqa: PLR0913, PLR0917 | |
|
|
||
| return external_services.taxonomy_service.get_common_name(tax_id) | ||
|
|
||
| @staticmethod | ||
| def check_ena_accession( # noqa: PLR0913, PLR0917 | ||
| input_data: InputMetadata, | ||
| output_field: str, | ||
| input_fields: list[str], | ||
| args: FunctionArgs, | ||
| context: ProcessingContext, | ||
| external_services: ExternalServices, | ||
| ) -> RawProcessingResult: | ||
| accession_type = args.get("accession_type") | ||
| if not isinstance(accession_type, str): | ||
| return raw_internal_error( | ||
| f"check_ena_accession requires an accession_type arg, got args {args}." | ||
| ) | ||
| accession: str | None = input_data.get("accession") | ||
| if not accession: | ||
| return RawProcessingResult() | ||
| if context.is_insdc_ingest_group: | ||
|
Contributor
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. This is also now a behavior change as before we checked the format of all accessions including those from the INSDC, we should probably continue to do this |
||
| return RawProcessingResult(datum=accession) | ||
| return external_services.ena_visibility_checker.check_visibility(accession, accession_type) | ||
|
|
||
|
|
||
| def single_metadata_annotation( | ||
| source_name: str, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.