| """Focused tests for the BindingDB curated-articles adapter. |
| |
| All fixtures are tiny synthetic ZIP archives built in tmp_path; no publisher |
| data, network access, or large files are involved. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import sys |
| import zipfile |
| from importlib import util as importlib_util |
| from pathlib import Path |
|
|
| import pytest |
| from mitointeract_recovery import bindingdb_adapter as adapter |
| from mitointeract_recovery.chemistry import canonicalize_smiles, stable_id |
| from mitointeract_recovery.source_measurement import ( |
| MeasurementRelation, |
| MeasurementType, |
| normalize_sequence, |
| ) |
|
|
| SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "prepare_bindingdb_gold_kd.py" |
| _spec = importlib_util.spec_from_file_location("prepare_bindingdb_gold_kd", SCRIPT_PATH) |
| assert _spec is not None and _spec.loader is not None |
| prepare_script = importlib_util.module_from_spec(_spec) |
| _spec.loader.exec_module(prepare_script) |
|
|
| PAD_PREFIX = "PAD" |
| EXTRA_HEADER_FIELDS = [ |
| adapter.COL_PH, |
| adapter.COL_TEMP, |
| adapter.COL_CURATION, |
| adapter.COL_ARTICLE_DOI, |
| adapter.COL_ENTRY_DOI, |
| adapter.COL_PMID, |
| adapter.COL_PUBCHEM_AID, |
| adapter.COL_PATENT, |
| adapter.COL_DATE_PUBLICATION, |
| adapter.COL_DATE_BINDINGDB, |
| adapter.COL_NUM_CHAINS, |
| adapter.COL_SEQUENCE_1, |
| adapter.COL_PUBCHEM_CID, |
| adapter.COL_PUBCHEM_SID, |
| adapter.COL_CHEBI_ID, |
| adapter.COL_CHEMBL_ID, |
| adapter.COL_DRUGBANK_ID, |
| adapter.COL_KEGG_ID, |
| adapter.COL_ZINC_ID, |
| "UniProt (SwissProt) Primary ID of Target Chain 1", |
| "UniProt (TrEMBL) Primary ID of Target Chain 1", |
| ] |
| PAD_FIELD_COUNT = ( |
| adapter.MAIN_EXPECTED_COLUMN_COUNT |
| - len(adapter.MAIN_HEADER_PREFIX) |
| - len(EXTRA_HEADER_FIELDS) |
| ) |
|
|
| SEQ_SRC = "MGSNKSKPKDASQRRR" |
| SEQ_HSP90 = "MPEHHQTETQPMPAET" |
| SEQ_MUT = "MUTATEDKINASE" |
|
|
| SMILES_A = "COc1cc2c(Nc3ccc(Cl)cc3Cl)c(cnc2cc1O)C#N" |
| SMILES_B = "c1ccncc1" |
| SMILES_C = "CC(=O)Oc1ccccc1C(=O)O" |
|
|
| ROW_KI_EXACT = { |
| "Ki (nM)": " 8.7", |
| "IC50 (nM)": "", |
| "Kd (nM)": "", |
| "EC50 (nM)": "", |
| } |
| ROW_MULTI_TYPE = { |
| "Ki (nM)": ">1000", |
| "IC50 (nM)": "50", |
| "Kd (nM)": "8.7", |
| "EC50 (nM)": "~200", |
| } |
| ROW_GOLD = { |
| "Ki (nM)": "5", |
| "IC50 (nM)": "", |
| "Kd (nM)": " 8.7", |
| "EC50 (nM)": "", |
| } |
| ROW_MALFORMED = { |
| "Ki (nM)": "abc", |
| "IC50 (nM)": "10-20", |
| "Kd (nM)": "-5", |
| "EC50 (nM)": "0", |
| } |
| ROW_KD_CENSORED = { |
| "Ki (nM)": "", |
| "IC50 (nM)": "", |
| "Kd (nM)": ">100", |
| "EC50 (nM)": "", |
| } |
|
|
|
|
| def _base_row( |
| rsid, |
| *, |
| smiles=SMILES_A, |
| target_name="Proto-oncogene tyrosine-protein kinase Src", |
| organism="Homo sapiens", |
| chains="1", |
| sequence=SEQ_SRC, |
| sp="P12931", |
| tr="", |
| doi="10.1016/j.bmcl.2003.07.001", |
| pmid="14552782", |
| pub_date="11/3/2003", |
| bdb_date="12/12/2017", |
| curation="Curated from the literature by BindingDB", |
| ph="7.4", |
| temp="37.00 C", |
| cells=None, |
| ): |
| row = {PAD_PREFIX: ""} |
| row.update( |
| { |
| "BindingDB Reactant_set_id": str(rsid), |
| "Ligand SMILES": smiles, |
| "Ligand InChI": "InChI=1S/synthetic", |
| "Ligand InChI Key": "SYNTHETICINCHIKEY-AAAA", |
| "BindingDB MonomerID": "4521", |
| "BindingDB Ligand Name": "synthetic ligand", |
| "Target Name": target_name, |
| "Target Source Organism According to Curator or DataSource": organism, |
| "Ki (nM)": "", |
| "IC50 (nM)": "", |
| "Kd (nM)": "", |
| "EC50 (nM)": "", |
| "pH": ph, |
| "Temp (C)": temp, |
| "Curation/DataSource": curation, |
| "Article DOI": doi, |
| "BindingDB Entry DOI": "10.7270/Q27942VF", |
| "PMID": pmid, |
| "PubChem AID": "", |
| "Patent Number": "", |
| "Date of publication": pub_date, |
| "Date in BindingDB": bdb_date, |
| "Number of Protein Chains in Target (>1 implies a multichain complex)": chains, |
| "BindingDB Target Chain Sequence 1": sequence, |
| "PubChem CID": "5328914", |
| "PubChem SID": "8034189", |
| "ChEBI ID of Ligand": "", |
| "ChEMBL ID of Ligand": "CHEMBL941", |
| "DrugBank ID of Ligand": "", |
| "KEGG ID of Ligand": "", |
| "ZINC ID of Ligand": "", |
| "UniProt (SwissProt) Primary ID of Target Chain 1": sp, |
| "UniProt (TrEMBL) Primary ID of Target Chain 1": tr, |
| } |
| ) |
| if cells: |
| row.update(cells) |
| return row |
|
|
|
|
| MAIN_ROWS = [ |
| _base_row("1", cells=ROW_KI_EXACT), |
| _base_row( |
| "2", |
| smiles=SMILES_B, |
| target_name="Heat shock protein HSP 90-alpha", |
| sequence=SEQ_HSP90, |
| sp="P07900", |
| doi="10.1000/xyz", |
| pmid="99999999", |
| pub_date="1/1/2020", |
| cells=ROW_MULTI_TYPE, |
| ), |
| _base_row( |
| "3", |
| smiles=SMILES_C, |
| target_name="Mutated Src kinase", |
| sequence=SEQ_MUT, |
| sp="", |
| tr="A0A023GPI8", |
| doi="", |
| pmid="12345678", |
| pub_date="5/5/2010", |
| ph="", |
| temp="", |
| cells=ROW_GOLD, |
| ), |
| _base_row("4", cells=ROW_MALFORMED), |
| _base_row("5", chains="2", cells=ROW_KI_EXACT), |
| _base_row("6", sequence=" ", cells=ROW_KI_EXACT), |
| _base_row("7", smiles="not_a_smiles", cells=ROW_KI_EXACT), |
| _base_row("9999", cells=ROW_KI_EXACT), |
| _base_row("10", cells=ROW_KD_CENSORED), |
| ] |
|
|
| MAPPING_LINES = [ |
| ("1", "100_1"), |
| ("2", "100_1"), |
| ("2", "100_2"), |
| ("2", "100_2"), |
| ("3", "200_1"), |
| ("4", "300_1"), |
| ("5", "100_1"), |
| ("6", "100_1"), |
| ("7", "100_1"), |
| ("10", "404_1"), |
| ("11", "100_1"), |
| ] |
|
|
| ASSAY_ROWS = [ |
| ("100", "1", "Src kinase inhibition assay", "Hot天 kinase reaction at pH 7.4."), |
| ( |
| "100", |
| "2", |
| "Src SPR direct binding", |
| "Surface plasmon resonance, immobilized Src.", |
| ), |
| ("200", "1", "Displacement assay", "Radioligand displacement at 37 C."), |
| ("300", "1", "", ""), |
| ] |
|
|
|
|
| def main_header(): |
| return ( |
| list(adapter.MAIN_HEADER_PREFIX) |
| + EXTRA_HEADER_FIELDS |
| + [f"{PAD_PREFIX} {index}" for index in range(PAD_FIELD_COUNT)] |
| ) |
|
|
|
|
| def main_lines(): |
| lines = ["\t".join(main_header())] |
| for row in MAIN_ROWS: |
| lines.append("\t".join(row.get(name, "") for name in main_header())) |
| return lines |
|
|
|
|
| def write_zip(path: Path, member: str, lines: list[str]) -> str: |
| payload = ("\n".join(lines) + "\n").encode("utf-8") |
| with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: |
| archive.writestr(member, payload) |
| return hashlib.sha256(path.read_bytes()).hexdigest() |
|
|
|
|
| def fixture_zips(tmp_path): |
| paths = { |
| "main_zip": tmp_path / "main.zip", |
| "mapping_zip": tmp_path / "mapping.zip", |
| "assays_zip": tmp_path / "assays.zip", |
| } |
| checksums = { |
| "main_zip": write_zip(paths["main_zip"], adapter.MAIN_MEMBER, main_lines()), |
| "mapping_zip": write_zip( |
| paths["mapping_zip"], |
| adapter.MAPPING_MEMBER, |
| ["\t".join(adapter.MAPPING_HEADER)] |
| + ["\t".join(pair) for pair in MAPPING_LINES], |
| ), |
| "assays_zip": write_zip( |
| paths["assays_zip"], |
| adapter.ASSAYS_MEMBER, |
| ["\t".join(adapter.ASSAYS_HEADER)] + ["\t".join(row) for row in ASSAY_ROWS], |
| ), |
| } |
| return paths, checksums |
|
|
|
|
| @pytest.fixture(name="fixture_zips") |
| def fixture_zips_fixture(tmp_path): |
| return fixture_zips(tmp_path) |
|
|
|
|
| def make_config(checksums): |
| return { |
| "release": "fixture", |
| "files": { |
| key: { |
| "url": f"https://fixtures.invalid/{key}.zip", |
| "zip_name": f"{key}.zip", |
| "member": adapter.DEFAULT_MEMBERS[key], |
| "sha256": checksums[key], |
| } |
| for key in adapter.INPUT_KEYS |
| }, |
| } |
|
|
|
|
| def run_fixture_pipeline(fixture_zips, tmp_path, **overrides): |
| paths, checksums = fixture_zips |
| kwargs = { |
| "main_zip": paths["main_zip"], |
| "mapping_zip": paths["mapping_zip"], |
| "assays_zip": paths["assays_zip"], |
| "config": make_config(checksums), |
| "output_dir": tmp_path / "out", |
| } |
| kwargs.update(overrides) |
| return adapter.run_pipeline(**kwargs) |
|
|
|
|
| def read_jsonl(path: Path) -> list[dict]: |
| return [json.loads(line) for line in path.read_text().splitlines()] |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_missing_release_aborts_before_parsing(fixture_zips, tmp_path): |
| paths, checksums = fixture_zips |
| config = make_config(checksums) |
| config["release"] = "" |
| with pytest.raises(ValueError, match="non-empty release"): |
| adapter.run_pipeline( |
| main_zip=paths["main_zip"], |
| mapping_zip=paths["mapping_zip"], |
| assays_zip=paths["assays_zip"], |
| config=config, |
| output_dir=tmp_path / "out", |
| ) |
|
|
|
|
| def test_checksum_mismatch_aborts_before_parsing(fixture_zips, tmp_path): |
| paths, _ = fixture_zips |
| bad_config = make_config({key: "0" * 64 for key in adapter.INPUT_KEYS}) |
| with pytest.raises(ValueError, match="SHA-256 mismatch"): |
| adapter.run_pipeline( |
| main_zip=paths["main_zip"], |
| mapping_zip=paths["mapping_zip"], |
| assays_zip=paths["assays_zip"], |
| config=bad_config, |
| output_dir=tmp_path / "out", |
| ) |
| assert not (tmp_path / "out" / "source_records.jsonl").exists() |
|
|
|
|
| def test_verify_sha256_round_trip(tmp_path): |
| payload = tmp_path / "x.bin" |
| payload.write_bytes(b"synthetic") |
| digest = hashlib.sha256(b"synthetic").hexdigest() |
| assert adapter.verify_sha256(payload, digest) == digest |
| with pytest.raises(ValueError, match="SHA-256 mismatch"): |
| adapter.verify_sha256(payload, "f" * 64) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def parse_cell(raw): |
| return adapter.parse_measurement_cell( |
| raw, |
| MeasurementType.KD, |
| source_record_id="r|a|Kd", |
| protein_sequence="ACD", |
| smiles="CCO", |
| ) |
|
|
|
|
| def test_cell_relation_prefixes_parse_exactly(): |
| assert parse_cell(" 8.7").relation is MeasurementRelation.EXACT |
| assert parse_cell("=8.7").relation is MeasurementRelation.EXACT |
| assert parse_cell("<8.7").relation is MeasurementRelation.LESS_THAN |
| assert parse_cell("<=8.7").relation is MeasurementRelation.LESS_THAN_OR_EQUAL |
| assert parse_cell(">8.7").relation is MeasurementRelation.GREATER_THAN |
| assert parse_cell(">=8.7").relation is MeasurementRelation.GREATER_THAN_OR_EQUAL |
| assert parse_cell("~8.7").relation is MeasurementRelation.APPROXIMATE |
| scientific = parse_cell(">1.00e+5") |
| assert scientific.relation is MeasurementRelation.GREATER_THAN |
| assert scientific.measurement.value == pytest.approx(100000.0) |
| assert parse_cell("").measurement is None |
| assert parse_cell(" ").measurement is None |
| assert parse_cell(None).measurement is None |
|
|
|
|
| def test_cell_malformed_range_and_nonpositive_rejected(): |
| assert parse_cell("10-20").rejection_reason == "range_value:Kd" |
| assert parse_cell("10 to 20").rejection_reason == "range_value:Kd" |
| assert parse_cell("abc").rejection_reason == "non_numeric:Kd" |
| assert parse_cell("8.7 nM").rejection_reason == "malformed_value:Kd" |
| assert parse_cell("Kd (nM)").rejection_reason == "repeated_column_header:Kd" |
| assert parse_cell("0").rejection_reason == "nonpositive_value:Kd" |
| assert parse_cell("-5").rejection_reason == "nonpositive_value:Kd" |
| assert parse_cell("1e-9999").rejection_reason == "nonpositive_value:Kd" |
| assert parse_cell("NaN").rejection_reason == "nonfinite_value:Kd" |
| assert parse_cell("inf").rejection_reason == "nonfinite_value:Kd" |
| assert parse_cell("1e9999").rejection_reason == "nonfinite_value:Kd" |
|
|
|
|
| def test_censor_reversal_on_log_transform(): |
| censored = adapter.parse_measurement_cell( |
| ">8.7", |
| MeasurementType.KD, |
| source_record_id="r|a|Kd", |
| protein_sequence="ACD", |
| smiles="CCO", |
| ).measurement |
| pbound, relation = censored.pbound() |
| assert pbound == pytest.approx(8.060480747381382) |
| assert relation is MeasurementRelation.LESS_THAN |
| assert censored.relation is MeasurementRelation.GREATER_THAN |
|
|
|
|
| |
| |
| |
|
|
|
|
| def fixture_context(tmp_path): |
| paths, checksums = fixture_zips(tmp_path) |
| assay_map = adapter.load_assay_map(paths["mapping_zip"]) |
| assay_info = adapter.load_assay_info(paths["assays_zip"]) |
| return assay_map, assay_info, make_config(checksums)["files"] |
|
|
|
|
| def process_single_row(tmp_path, row, row_number=1): |
| assay_map, assay_info, files = fixture_context(tmp_path) |
| return adapter.process_row( |
| row, |
| row_number=row_number, |
| assay_map=assay_map, |
| assay_info=assay_info, |
| source_release="fixture", |
| config_files=files, |
| swissprot_column="UniProt (SwissProt) Primary ID of Target Chain 1", |
| trembl_column="UniProt (TrEMBL) Primary ID of Target Chain 1", |
| ) |
|
|
|
|
| def test_multiple_measurement_types_from_one_row_no_relabel(tmp_path): |
| records, row_rejections, cell_rejections, _ = process_single_row( |
| tmp_path, _base_row("2", cells=ROW_MULTI_TYPE) |
| ) |
| assert row_rejections == [] |
| assert cell_rejections == {} |
| |
| assert len(records) == 8 |
| first_assay = [ |
| r for r in records if r["source_record"]["entryid_assayid"] == "100_1" |
| ] |
| assert [r["source_record"]["measurement"]["type"] for r in first_assay] == [ |
| "Ki", |
| "IC50", |
| "Kd", |
| "EC50", |
| ] |
| |
| ki = next( |
| r for r in first_assay if r["source_record"]["measurement"]["type"] == "Ki" |
| ) |
| assert ki["source_record"]["measurement"]["type"] == "Ki" |
| assert ki["source_record"]["measurement"]["relation"] == ">" |
| assert ki["source_record"]["measurement"]["value"] == pytest.approx(1000.0) |
| |
| ids = [r["source_record"]["source_record_id"] for r in records] |
| assert len(ids) == len(set(ids)) == 8 |
| assert "2|row:1|100_1|Kd" in ids and "2|row:1|100_2|Ki" in ids |
|
|
|
|
| def test_repeated_reactant_set_id_uses_source_row_ordinal(tmp_path): |
| first, _, _, _ = process_single_row( |
| tmp_path, _base_row("2", cells=ROW_GOLD), row_number=10 |
| ) |
| second, _, _, _ = process_single_row( |
| tmp_path, _base_row("2", cells=ROW_GOLD), row_number=11 |
| ) |
| first_ids = {record["source_record"]["source_record_id"] for record in first} |
| second_ids = {record["source_record"]["source_record_id"] for record in second} |
| assert first_ids.isdisjoint(second_ids) |
| assert all("|row:10|" in record_id for record_id in first_ids) |
| assert all("|row:11|" in record_id for record_id in second_ids) |
| audit = adapter.AuditBuilder() |
| audit.observe_source_row(_base_row("2", cells=ROW_GOLD)) |
| audit.observe_source_row(_base_row("2", cells=ROW_GOLD)) |
| stats = audit.duplicate_pair_stats() |
| assert stats["duplicate_reactant_set_ids"] == 1 |
| assert stats["extra_reactant_set_rows"] == 1 |
|
|
|
|
| def test_provenance_fields_survive_into_source_envelope(tmp_path): |
| records, _, _, _ = process_single_row(tmp_path, MAIN_ROWS[2]) |
| assert records[0]["source_record"]["reactant_set_id"] == "3" |
| assert len(records) == 2 |
| record = next( |
| r for r in records if r["source_record"]["measurement"]["type"] == "Kd" |
| ) |
| assert record["schema_version"] == adapter.SCHEMA_VERSION |
| assert record["source_database"] == "BindingDB" |
| assert record["source_release"] == "fixture" |
| assert set(record["source_release_files"]) == set(adapter.INPUT_KEYS) |
| for entry in record["source_release_files"].values(): |
| assert set(entry) == {"zip_name", "url", "member", "sha256"} |
| assert len(entry["sha256"]) == 64 |
| assert not entry["url"].startswith("/") |
| source = record["source_record"] |
| assert source["reactant_set_id"] == "3" |
| assert source["main_row_number"] == 1 |
| measurement = source["measurement"] |
| assert measurement["raw_value"] == "8.7" |
| assert measurement["unit"] == "nM" |
| assert measurement["relation"] == "=" |
| assert measurement["is_exact"] is True |
| provenance = source["provenance"] |
| assert provenance["target_name"] == "Mutated Src kinase" |
| assert provenance["target_organism"] == "Homo sapiens" |
| assert provenance["chain_count"] == 1 |
| assert provenance["chain1_sequence"] == normalize_sequence(SEQ_MUT) |
| assert provenance["swissprot_primary_id"] is None |
| assert provenance["trembl_primary_id"] == "A0A023GPI8" |
| assert provenance["article_doi"] is None |
| assert provenance["pmid"] == "12345678" |
| assert provenance["publication_date"] == "5/5/2010" |
| assert provenance["bindingdb_date"] == "12/12/2017" |
| assert ( |
| provenance["curation_datasource"] == "Curated from the literature by BindingDB" |
| ) |
| assert provenance["ph"] is None |
| assert provenance["temp_c"] is None |
| assert provenance["assay"]["entryid_assayid"] == "200_1" |
| assert provenance["assay"]["entry_id"] == "200" |
| assert provenance["assay"]["assay_id"] == "1" |
| assert provenance["assay"]["assay_name"] == "Displacement assay" |
| assert "Radioligand" in provenance["assay"]["assay_description"] |
| assert provenance["assay"]["joined"] is True |
| assert provenance["ligand"]["smiles"] == SMILES_C |
| assert provenance["ligand"]["inchi_key"] == "SYNTHETICINCHIKEY-AAAA" |
| assert provenance["ligand"]["bindingdb_monomer_id"] == "4521" |
| assert provenance["ligand"]["pubchem_cid"] == "5328914" |
| assert provenance["ligand"]["chembl_id"] == "CHEMBL941" |
| canonical = source["canonical"] |
| assert canonical["smiles"] == canonicalize_smiles(SMILES_C) |
| assert canonical["protein_id"] == stable_id("protein", normalize_sequence(SEQ_MUT)) |
| assert canonical["pair_id"].startswith("pair_") |
| |
| json.dumps(record, sort_keys=True) |
|
|
|
|
| def test_row_level_rejections(tmp_path): |
| records, rejections, _, details = process_single_row( |
| tmp_path, _base_row("5", chains="2", cells=ROW_KI_EXACT) |
| ) |
| assert records == [] and details.multichain is True |
| assert [r.reasons for r in rejections] == [("multichain_target",)] |
|
|
| records, rejections, _, details = process_single_row( |
| tmp_path, _base_row("6", sequence=" ", cells=ROW_KI_EXACT) |
| ) |
| assert records == [] and details.missing_sequence is True |
| assert [r.reasons for r in rejections] == [("missing_chain1_sequence",)] |
|
|
| records, rejections, _, details = process_single_row( |
| tmp_path, _base_row("7", smiles="not_a_smiles", cells=ROW_KI_EXACT) |
| ) |
| assert records == [] and details.invalid_smiles is True |
| assert [r.reasons for r in rejections] == [("invalid_smiles",)] |
|
|
| records, rejections, cell_rejections, _ = process_single_row( |
| tmp_path, _base_row("4", cells=ROW_MALFORMED) |
| ) |
| assert records == [] |
| assert [r.reasons for r in rejections] == [("no_valid_measurement",)] |
| assert sum(cell_rejections.values()) == 4 |
|
|
| records, rejections, cell_rejections, _ = process_single_row( |
| tmp_path, _base_row("1", cells={}) |
| ) |
| assert records == [] and cell_rejections == {} |
| assert [r.reasons for r in rejections] == [("no_supported_measurement",)] |
|
|
| records, rejections, _, _ = process_single_row( |
| tmp_path, _base_row("9999", cells=ROW_KI_EXACT) |
| ) |
| assert rejections == [] |
| assert len(records) == 1 |
| source = records[0]["source_record"] |
| assert source["source_record_id"] == "9999|row:1|UNMAPPED|Ki" |
| assert source["entryid_assayid"] is None |
| assert source["provenance"]["assay"]["joined"] is False |
|
|
| records, rejections, _, _ = process_single_row( |
| tmp_path, _base_row("8", chains="", cells=ROW_KI_EXACT) |
| ) |
| assert records == [] |
| assert [r.reasons for r in rejections] == [("missing_chain_count",)] |
|
|
| records, rejections, _, _ = process_single_row( |
| tmp_path, _base_row("8", chains="1.0", cells=ROW_KI_EXACT) |
| ) |
| assert records == [] |
| assert [r.reasons for r in rejections] == [("invalid_chain_count",)] |
|
|
|
|
| def test_missing_assay_join_kept_in_source_but_not_gold(tmp_path): |
| records, rejections, cell_rejections, _ = process_single_row( |
| tmp_path, _base_row("10", cells=ROW_KD_CENSORED) |
| ) |
| assert rejections == [] and cell_rejections == {} |
| assert len(records) == 1 |
| assay = records[0]["source_record"]["provenance"]["assay"] |
| assert assay["joined"] is False |
| assert assay["assay_name"] is None |
| assert assay["assay_description"] is None |
| audit = adapter.AuditBuilder() |
| audit.observe_record(records[0]) |
| assert audit.records_missing_assay_join == 1 |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_end_to_end_counts_gold_and_audit(fixture_zips, tmp_path, capsys): |
| audit = run_fixture_pipeline(fixture_zips, tmp_path) |
| capsys.readouterr() |
|
|
| assert audit["source_rows"] == 9 |
| assert audit["source_release"] == "fixture" |
| assert audit["rows_with_emitted_measurements"] == 5 |
| emitted = audit["emitted_records"] |
| assert emitted["total"] == 13 |
| assert emitted["by_type"] == {"EC50": 2, "IC50": 2, "Kd": 4, "Ki": 5} |
| assert emitted["by_relation"] == {"=": 8, ">": 3, "~": 2} |
| assert emitted["by_type_relation"]["Ki|="] == 3 |
| assert emitted["by_type_relation"]["Ki|>"] == 2 |
| assert emitted["by_type_relation"]["Kd|="] == 3 |
| assert emitted["by_type_relation"]["Kd|>"] == 1 |
| assert emitted["by_type_relation"]["EC50|~"] == 2 |
|
|
| |
| assert audit["exact_gold_kd_records"] == 3 |
| assert audit["gold_exact_kd"] == { |
| "records": 3, |
| "unique_pairs": 2, |
| "unique_proteins": 2, |
| "unique_assays": 3, |
| "pkd_min": pytest.approx(8.060480747381382), |
| "pkd_max": pytest.approx(8.060480747381382), |
| "pkd_below_3_review_flag": 0, |
| "pkd_above_12_review_flag": 0, |
| "review_flags_are_not_filters": True, |
| } |
| assert audit["outputs"]["source_records.jsonl"]["records"] == 13 |
| assert audit["outputs"]["gold_exact_kd.jsonl"]["records"] == 3 |
| assert len(audit["outputs"]["source_records.jsonl"]["sha256"]) == 64 |
| assert len(audit["outputs"]["gold_exact_kd.jsonl"]["sha256"]) == 64 |
| assert audit["rejections"]["gold_excluded"] == { |
| "missing_assay_join": 1, |
| "non_exact_relation": 1, |
| "non_kd_type": 9, |
| } |
| assert audit["rejections"]["row_level"] == { |
| "invalid_smiles": 1, |
| "missing_chain1_sequence": 1, |
| "multichain_target": 1, |
| "no_valid_measurement": 1, |
| } |
| assert audit["rejections"]["cell_level"] == { |
| "range_value:IC50": 1, |
| "non_numeric:Ki": 1, |
| "nonpositive_value:EC50": 1, |
| "nonpositive_value:Kd": 1, |
| } |
| assert audit["rejections"]["rows_flagged_multichain"] == 1 |
| assert audit["rejections"]["rows_flagged_invalid_smiles"] == 1 |
| assert audit["rejections"]["rows_flagged_missing_sequence"] == 1 |
|
|
| assert audit["coverage"]["records_with_assay_join"] == 11 |
| assert audit["coverage"]["records_missing_assay_join"] == 2 |
| assert audit["coverage"]["records_with_citation_doi_or_pmid"] == 13 |
| assert audit["coverage"]["records_with_publication_date"] == 13 |
|
|
| assert audit["uniques"] == {"proteins": 3, "ligands": 3, "pairs": 3, "assays": 4} |
| assert audit["duplicates"] == { |
| "duplicate_pairs_across_assays": 2, |
| "extra_assay_instances": 2, |
| "pairs_with_multiple_measurement_types": 3, |
| "duplicate_reactant_set_ids": 0, |
| "extra_reactant_set_rows": 0, |
| } |
|
|
| out = tmp_path / "out" |
| source_records = read_jsonl(out / "source_records.jsonl") |
| gold = read_jsonl(out / "gold_exact_kd.jsonl") |
| assert len(source_records) == 13 |
| assert len(gold) == 3 |
|
|
| |
| assert {row["measurement_type"] for row in gold} == {"Kd"} |
| assert all(row["relation"] == "=" for row in gold) |
| assert all(row["kd_nm"] > 0 for row in gold) |
| gold_row = next( |
| row for row in gold if row["source_record_id"] == "3|row:3|200_1|Kd" |
| ) |
| assert gold_row["kd_nm"] == pytest.approx(8.7) |
| assert gold_row["pkd"] == pytest.approx(8.060480747381382) |
| assert gold_row["sequence"] == normalize_sequence(SEQ_MUT) |
| assert gold_row["smiles"] == canonicalize_smiles(SMILES_C) |
| assert gold_row["assay"]["assay_name"] == "Displacement assay" |
| assert gold_row["citation"]["pmid"] == "12345678" |
| assert gold_row["raw_value"] == "8.7" |
|
|
| |
| row_numbers = [r["source_record"]["main_row_number"] for r in source_records] |
| assert row_numbers == sorted(row_numbers) |
| rsid2 = [r for r in source_records if r["source_record"]["reactant_set_id"] == "2"] |
| assert [r["source_record"]["entryid_assayid"] for r in rsid2] == [ |
| "100_1", |
| "100_1", |
| "100_1", |
| "100_1", |
| "100_2", |
| "100_2", |
| "100_2", |
| "100_2", |
| ] |
| assert [r["source_record"]["measurement"]["type"] for r in rsid2[:4]] == [ |
| "Ki", |
| "IC50", |
| "Kd", |
| "EC50", |
| ] |
| |
| src_assay = next( |
| r for r in source_records if r["source_record"]["entryid_assayid"] == "100_1" |
| ) |
| assert ( |
| "Hot天" |
| in src_assay["source_record"]["provenance"]["assay"]["assay_description"] |
| ) |
|
|
| on_disk_audit = json.loads((out / "bindingdb_audit.json").read_text()) |
| assert on_disk_audit == audit |
|
|
|
|
| def test_deterministic_output_across_runs(fixture_zips, tmp_path, capsys): |
| first_dir = tmp_path / "first" |
| second_dir = tmp_path / "second" |
| paths, checksums = fixture_zips |
| config = make_config(checksums) |
| adapter.run_pipeline( |
| main_zip=paths["main_zip"], |
| mapping_zip=paths["mapping_zip"], |
| assays_zip=paths["assays_zip"], |
| config=config, |
| output_dir=first_dir, |
| ) |
| adapter.run_pipeline( |
| main_zip=paths["main_zip"], |
| mapping_zip=paths["mapping_zip"], |
| assays_zip=paths["assays_zip"], |
| config=config, |
| output_dir=second_dir, |
| ) |
| capsys.readouterr() |
| for name in ("source_records.jsonl", "gold_exact_kd.jsonl", "bindingdb_audit.json"): |
| assert (first_dir / name).read_bytes() == (second_dir / name).read_bytes() |
|
|
|
|
| def test_max_source_rows_limits_processing(fixture_zips, tmp_path, capsys): |
| audit = run_fixture_pipeline(fixture_zips, tmp_path, max_source_rows=1) |
| capsys.readouterr() |
| assert audit["source_rows"] == 1 |
| assert audit["emitted_records"]["total"] == 1 |
| assert audit["emitted_records"]["by_type"] == {"Ki": 1} |
| assert audit["exact_gold_kd_records"] == 0 |
|
|
|
|
| def test_cli_end_to_end_on_fixture_zips(fixture_zips, tmp_path, monkeypatch, capsys): |
| paths, checksums = fixture_zips |
| config_path = tmp_path / "config.json" |
| config_path.write_text(json.dumps(make_config(checksums))) |
| output_dir = tmp_path / "cli-out" |
| monkeypatch.setattr( |
| sys, |
| "argv", |
| [ |
| "prepare_bindingdb_gold_kd.py", |
| "--main-zip", |
| str(paths["main_zip"]), |
| "--mapping-zip", |
| str(paths["mapping_zip"]), |
| "--assays-zip", |
| str(paths["assays_zip"]), |
| "--config", |
| str(config_path), |
| "--output-dir", |
| str(output_dir), |
| ], |
| ) |
| prepare_script.main() |
| capsys.readouterr() |
| audit = json.loads((output_dir / "bindingdb_audit.json").read_text()) |
| assert audit["source_rows"] == 9 |
| assert audit["exact_gold_kd_records"] == 3 |
| assert len(read_jsonl(output_dir / "source_records.jsonl")) == 13 |
| assert len(read_jsonl(output_dir / "gold_exact_kd.jsonl")) == 3 |
|
|
|
|
| def test_cli_rejects_invalid_max_source_rows(fixture_zips, tmp_path, monkeypatch): |
| paths, checksums = fixture_zips |
| config_path = tmp_path / "config.json" |
| config_path.write_text(json.dumps(make_config(checksums))) |
| monkeypatch.setattr( |
| sys, |
| "argv", |
| [ |
| "prepare_bindingdb_gold_kd.py", |
| "--main-zip", |
| str(paths["main_zip"]), |
| "--mapping-zip", |
| str(paths["mapping_zip"]), |
| "--assays-zip", |
| str(paths["assays_zip"]), |
| "--config", |
| str(config_path), |
| "--output-dir", |
| str(tmp_path / "cli-out"), |
| "--max-source-rows", |
| "0", |
| ], |
| ) |
| with pytest.raises(SystemExit): |
| prepare_script.main() |
|
|
|
|
| def test_load_config_validates_required_fields(tmp_path): |
| bad = tmp_path / "bad.json" |
| bad.write_text(json.dumps({"files": {}})) |
| with pytest.raises(ValueError, match="config files entry"): |
| prepare_script.load_config(bad) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_missing_zip_member_raises(tmp_path): |
| archive_path = tmp_path / "wrong.zip" |
| write_zip(archive_path, "something_else.tsv", ["a\tb", "1\t2"]) |
| with pytest.raises(ValueError, match="does not contain member"): |
| adapter.load_assay_map(archive_path, adapter.MAPPING_MEMBER) |
|
|
|
|
| def test_main_header_guard_rejects_unexpected_layout(tmp_path): |
| archive_path = tmp_path / "bad_main.zip" |
| write_zip(archive_path, adapter.MAIN_MEMBER, ["a\tb\tc", "1\t2\t3"]) |
| with pytest.raises(ValueError, match="unexpected header prefix"): |
| list(adapter.stream_main_rows(archive_path)) |
|
|
|
|
| def test_main_column_count_guard(tmp_path): |
| header = list(adapter.MAIN_HEADER_PREFIX) + ["only one pad"] |
| archive_path = tmp_path / "short_main.zip" |
| write_zip( |
| archive_path, |
| adapter.MAIN_MEMBER, |
| ["\t".join(header), "\t".join(["x"] * len(header))], |
| ) |
| with pytest.raises(ValueError, match="expected 640 columns"): |
| list(adapter.stream_main_rows(archive_path)) |
|
|
|
|
| def test_ragged_data_row_raises(tmp_path): |
| archive_path = tmp_path / "ragged.zip" |
| lines = main_lines() |
| lines[1] = "\t".join(lines[1].split("\t")[:10]) |
| write_zip(archive_path, adapter.MAIN_MEMBER, lines) |
| with pytest.raises(ValueError, match="data row 1 has 10 fields"): |
| list(adapter.stream_main_rows(archive_path)) |
|
|
|
|
| def test_assay_map_collapses_duplicate_lines(fixture_zips): |
| paths, _ = fixture_zips |
| assay_map = adapter.load_assay_map(paths["mapping_zip"]) |
| assert assay_map["2"] == ["100_1", "100_2"] |
| assert assay_map["1"] == ["100_1"] |
|
|
|
|
| def test_resolve_column_matches_suffix_variants(): |
| header = [ |
| "UniProt (SwissProt) Primary ID of Target Chain 1 Target Chain 1", |
| "UniProt (TrEMBL) Primary ID of Target Chain 1", |
| ] |
| assert ( |
| adapter.resolve_column(header, adapter.SWISSPROT_PRIMARY_RE) |
| == "UniProt (SwissProt) Primary ID of Target Chain 1 Target Chain 1" |
| ) |
| assert ( |
| adapter.resolve_column(header, adapter.TREMBL_PRIMARY_RE) |
| == "UniProt (TrEMBL) Primary ID of Target Chain 1" |
| ) |
|
|
|
|
| def test_blank_stream_is_explicitly_rejected(tmp_path): |
| records, rejections, cell_rejections, _ = process_single_row( |
| tmp_path, |
| _base_row( |
| "11", |
| cells={"Ki (nM)": "", "IC50 (nM)": "", "Kd (nM)": "", "EC50 (nM)": ""}, |
| ), |
| ) |
| assert records == [] and cell_rejections == {} |
| assert [r.reasons for r in rejections] == [("no_supported_measurement",)] |
|
|