diff --git a/docs/architecture/integration-adapters.md b/docs/architecture/integration-adapters.md index fa35ed3..3f152a1 100644 --- a/docs/architecture/integration-adapters.md +++ b/docs/architecture/integration-adapters.md @@ -60,9 +60,16 @@ was built first as the reference and the rest mirror it. - **Shared helpers removed duplication.** `metaseed._http.request_json` (retry/backoff) and `metaseed.isatab.to_isatab` are written once and reused by all the relevant adapters. -- **Schema-valid output where it counts.** The ENA exporter's study/sample/run - XML **validate against ENA's official SRA XSDs** — i.e. the output is not just - well-formed, it would be accepted. +- **Every exporter has a conformance test.** The ENA export **validates against + ENA's official SRA XSDs** (external schema — the gold standard); the PRIDE, + MetaboLights, and BrAPI exports have structural/shape conformance tests + (required px `MTD`/`FMH`/`FME` lines, the ISA-Tab sections + study/assay/MAF + files, and the BrAPI v2 object shape via jsonschema). All are `@network` + round-trips against a real record. +- **Importers tested across diverse accessions.** Each importer was run against + several real, varied accessions (ENA run/sample/study incl. a 495-file study; + four PXD projects incl. a 2384-file one; four MTBLS studies) without surfacing + new mapping gaps. ## What didn't work (and the lessons) @@ -90,9 +97,9 @@ was built first as the reference and the rest mirror it. ## Honest status -The eight adapters are a real first step, **not production-validated.** Each was -smoke-tested against a single live record (which already caught real bugs), and -the clients now page and retry. Still open: broader accession coverage, export -validation for BrAPI/PRIDE/MetaboLights (ENA is XSD-valid), and verification that -exports are accepted by the live submission systems. Tracking: umbrella #75, -importers epic #76. +The eight adapters now page and retry, each importer has been exercised across +several diverse live accessions, and every exporter has a conformance test +(ENA against the official XSD; the rest structural/shape). The one remaining +unknown is **acceptance by the live submission systems** — no export has yet +been round-tripped through a real ENA/PRIDE/MetaboLights/BrAPI submission +endpoint. Tracking: umbrella #75, importers epic #76. diff --git a/pyproject.toml b/pyproject.toml index 1affcad..1994d49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dev = [ "httpx>=0.27", "jupyter>=1.0", "xmlschema>=3.0", + "jsonschema>=4.0", ] docs = [ "mkdocs>=1.6,<2.0", diff --git a/tests/test_brapi/test_conformance.py b/tests/test_brapi/test_conformance.py new file mode 100644 index 0000000..f8c67bd --- /dev/null +++ b/tests/test_brapi/test_conformance.py @@ -0,0 +1,42 @@ +"""Conformance of the BrAPI export to the BrAPI v2 object structure. + +Uses jsonschema to assert the v2 shape: every object carries its DbId, and an +observation unit nests its level inside observationUnitPosition (never a +top-level ``observationLevel`` — the bug fixed in this adapter). Opt-in (network). +""" + +from __future__ import annotations + +import pytest +from jsonschema import validate + +from metaseed.brapi import import_brapi, to_brapi + +BASE_URL = "https://test-server.brapi.org/brapi/v2" + +_OU_SCHEMA = { + "type": "object", + "required": ["observationUnitDbId"], + "not": {"required": ["observationLevel"]}, # must be nested, not top-level + "properties": { + "observationUnitPosition": { + "type": "object", + "properties": {"observationLevel": {"type": "object"}}, + } + }, +} +_REQUIRED_ID = { + "studies": "studyDbId", + "germplasm": "germplasmDbId", + "observationUnits": "observationUnitDbId", +} + + +@pytest.mark.network +def test_brapi_export_matches_v2_shape(): + bodies = to_brapi(import_brapi(BASE_URL)) + for collection, id_field in _REQUIRED_ID.items(): + for obj in bodies.get(collection, []): + assert id_field in obj, f"{collection} object missing {id_field}" + for unit in bodies.get("observationUnits", []): + validate(instance=unit, schema=_OU_SCHEMA) diff --git a/tests/test_metabolights/test_conformance.py b/tests/test_metabolights/test_conformance.py new file mode 100644 index 0000000..ba5c94e --- /dev/null +++ b/tests/test_metabolights/test_conformance.py @@ -0,0 +1,46 @@ +"""Conformance of the MetaboLights export to the ISA-Tab structure. + +Structural validation (ISA-Tab has no installable schema here): the investigation +file must carry the required ISA sections, and the bundle must include the study, +assay, and MAF files with their mandatory headers. Opt-in (network). +""" + +from __future__ import annotations + +import pytest + +from metaseed.metabolights import import_accession, to_metabolights + +_REQUIRED_SECTIONS = ( + "ONTOLOGY SOURCE REFERENCE", + "INVESTIGATION", + "STUDY", + "STUDY FACTORS", + "STUDY ASSAYS", + "STUDY PROTOCOLS", + "STUDY CONTACTS", +) + + +def _isatab_violations(docs: dict[str, str]) -> list[str]: + errors = [] + inv = docs.get("i_Investigation.txt", "") + errors += [f"missing section {s}" for s in _REQUIRED_SECTIONS if s not in inv] + if "Investigation Identifier" not in inv: + errors.append("no Investigation Identifier row") + if not [n for n in docs if n.startswith("s_")]: + errors.append("no study (s_) file") + if not [n for n in docs if n.startswith("a_")]: + errors.append("no assay (a_) file") + if not [n for n in docs if n.startswith("m_")]: + errors.append("no MAF (m_) file") + for name in (n for n in docs if n.startswith("s_")): + if "Sample Name" not in docs[name].splitlines()[0]: + errors.append(f"{name} missing 'Sample Name' header") + return errors + + +@pytest.mark.network +def test_metabolights_export_is_isatab_conformant(): + docs = to_metabolights(import_accession("MTBLS1")) + assert _isatab_violations(docs) == [] diff --git a/tests/test_pride/test_conformance.py b/tests/test_pride/test_conformance.py new file mode 100644 index 0000000..c71f0eb --- /dev/null +++ b/tests/test_pride/test_conformance.py @@ -0,0 +1,55 @@ +"""Conformance of the PRIDE ``submission.px`` export to the px-submission format. + +Structural validation: PRIDE has no external schema (unlike ENA's SRA XSD), so +this asserts the px-submission-tool structure — the required ``MTD`` metadata +lines and a well-formed ``FMH``/``FME`` file-mapping section. Opt-in (network): +exports a real project and checks the result would be structurally accepted. +""" + +from __future__ import annotations + +import pytest + +from metaseed.pride import import_accession, to_pride_submission + +# MTD keys the px-submission tool requires for a project. +_REQUIRED_MTD = ( + "project_title", + "project_description", + "submitter_name", + "submitter_email", + "submission_type", +) + + +def _px_violations(text: str) -> list[str]: + lines = [ln for ln in text.splitlines() if ln] + mtd = { + parts[1] + for ln in lines + if (parts := ln.split("\t"))[0] == "MTD" and len(parts) >= 3 + } + errors = [f"missing MTD {k}" for k in _REQUIRED_MTD if k not in mtd] + if "species" not in mtd: + errors.append("no species line") + if "instrument" not in mtd: + errors.append("no instrument line") + + if not any(ln.startswith("FMH\t") for ln in lines): + errors.append("missing FMH header") + fme = [ln for ln in lines if ln.startswith("FME\t")] + if not fme: + errors.append("no FME file entries") + for ln in fme: + cols = ln.split("\t") + if len(cols) != 4: + errors.append(f"FME has {len(cols)} columns, expected 4: {ln!r}") + elif not cols[1].isdigit(): + errors.append(f"FME file_id not numeric: {ln!r}") + return errors + + +@pytest.mark.network +def test_pride_export_is_px_conformant(): + px = to_pride_submission(import_accession("PXD000001"))["submission.px"] + assert _px_violations(px) == []