just_dna_enricher.gwas¶
just_dna_enricher.gwas ¶
The NHGRI-EBI GWAS Catalog pass — published effect sizes as a derived fact table (0.6, RM90).
Fills gwas_effects.csv for the variants variants.csv names, from the Catalog's REST API. What it
does not do is fill weight: a consumer asked for exactly that and it is barred — MODULE_LIFECYCLE
§ Stage 3 names weight/direction/effect_size in the cells no tool fills, and every check in this
tier reports rather than repairs. The effect lands in its own table beside the authored column, and a
consumer chooses between them wholesale.
Everything below was established by probing the real API on 2026-08-17, not from its documentation, and three findings shaped the pass:
- The association payload is thin. It carries the effect, the p-value and the risk allele, and
nothing else —
pmid,study_accession,ancestry,traitandtrait_efo_idall sit behind_links.studyand_links.efoTraits. So a complete row costs two extra requests, and the pass costs1 + 2Nper variant with N associations.
_LinkCache memoizes by resolved URL, and the measurement corrected the prediction that
motivated it. The expectation was that associations share studies heavily, so caching would
collapse most of the cost. Run against reference_examples/hfe_hemochromatosis, it saved
nothing: rs1800562 alone carries 189 associations and each names its own study, so the real
figure was 382 requests and 0 cache hits. The cache stays — it costs a dict and it does pay on a
module whose variants share literature — but the honest budget is 1 + 2N, and --no-study-facts
exists because of that measurement rather than in anticipation of it. The pass reports both halves
so an operator sees the real number instead of this docstring's guess.
* riskAlleleName is rs4149056-C, or rs4149056-? when the study never established which allele
carries the effect — 2 of the first 3 associations for that variant. -? becomes None, never a
guess, and the row is still written: an effect relative to an unknown allele is real evidence that
cannot be used as a weight, and dropping it would hide that from a consumer who needs to know.
* betaUnit is free text and frequently uninterpretable — umol/l on one association and unit
on two others for the same variant. It is stored verbatim including the useless values, because
"these betas are on unknown and possibly different scales" is the fact a consumer must have.
Rate limits are NOT established. EBI publishes no numeric budget that could be found, which makes
this pass unlike gnomad.py, whose 10-requests-per-60-seconds is real, documented and load-bearing.
The gate here is a conservative default rather than a transcribed limit, and it is spelled that way so
nobody later "corrects" it against a number that does not exist.
GwasError ¶
Bases: RuntimeError
A GWAS Catalog fetch or parse failed in a way the caller must see.
Every transport and parse failure is translated into this before it leaves the module. A client
that leaks httpx.HTTPError has no contract — the caller would have to import the transport
library to catch it, and a swap of transport would silently become a breaking change.
GwasNotFound ¶
Bases: GwasError
The Catalog answered, and it has no record of this variant at all (HTTP 404).
A subclass rather than a flag, because this is an answer and not a failure. Found by running
the pass against a real module: the Catalog holds only variants with a published genome-wide
association, so rs111033563 — a rare clinical HFE variant — 404s rather than returning an empty
list. The first version of this pass treated that as an outage and died on the first rare variant
in the module, which is every clinically-authored module.
It stays inside GwasError so a caller that does not care about the distinction still catches
one type; associations_for catches it and reports the empty answer, which the pass records as
not_found. What must never happen is the reverse — a transport failure read as "no
associations", which would write a confident negative about a variant nobody could ask about.
GwasResult
dataclass
¶
GwasResult(
rows: list[GwasEffectRow] = list(),
covered: list[str] = list(),
missing: list[str] = list(),
requests_made: int = 0,
requests_saved: int = 0,
p_value_underflows: int = 0,
unusable: int = 0,
skipped_offline: bool = False,
)
What one pass did, including what it spent.
GwasCatalogClient
dataclass
¶
GwasCatalogClient(
endpoint: str = DEFAULT_GWAS_ENDPOINT,
timeout: float = 30.0,
gate: PacingGate = (
lambda: PacingGate(DEFAULT_REQUEST_INTERVAL)
)(),
_client: Client | None = None,
)
Thin REST client for the Catalog, paced and retried.
endpoint and gate are injectable so a test can drive the real parsing code against recorded
payloads without a network or a real sleep — the same shape EnsemblResolver and the gnomAD
client use.
associations_for ¶
Every association the Catalog holds for one rsID.
Three outcomes, not two — the S20 shape. A non-empty list is an answer; [] is also an
answer (the Catalog was reached and holds nothing for this variant, which the caller records
as not_found); and a raised GwasError means it could not be asked, which is unchecked
rather than empty. Fusing the last two would turn a failed request into a definite negative.
Source code in enricher/src/just_dna_enricher/gwas.py
follow ¶
A linked sub-resource, or {} when the Catalog has none.
A 404 here withholds the study/trait facts for one association rather than sinking the pass: the association itself is still a real published effect, and dropping it because its study record moved would lose evidence over metadata.
Source code in enricher/src/just_dna_enricher/gwas.py
enrich_gwas ¶
enrich_gwas(
spec_dir: Path,
*,
mode: str = "best_effort",
offline: bool = False,
write: bool = True,
client: GwasCatalogClient | None = None,
dataset: str | None = None,
declared_use: str = "unstated",
study_facts: bool = True,
) -> GwasResult
Record the Catalog's published effect sizes for this module's variants.
Existing rows are authoritative and merged, never clobbered — the standing rule for every pass, with the standing consequence: to regenerate after a machinery change, delete the file first.
offline makes the pass a no-op with a warning rather than a failure: the Catalog publishes a
bulk download, but this pass reads the REST API, and there is no snapshot for it to fall back on.
An injected client still wins, because handing over a transport you already hold is not egress.
A variant the Catalog holds nothing for gets a not_found row, unlike
enrich_gene_validity's silence, and the difference is real rather than stylistic: a curating
body's silence means nobody has assessed the gene, whereas the Catalog's empty answer means no
genome-wide association has been published for this variant — which is a fact about it, and one
a consumer weighing an authored weight against the literature wants to see.
mode is the severity ladder the CLI's --strict sets, and it is deliberately not about
missing: see the block above the raise at the end of this function for why the Catalog's empty
answer is a fact rather than a shortfall, and what strict reads instead.
Source code in enricher/src/just_dna_enricher/gwas.py
473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 | |
gwas_source_row ¶
The licence row this pass writes. Exposed so a caller can record the terms without fetching.