Skip to content

just_dna_enricher.clingen_allele

just_dna_enricher.clingen_allele

The ClinGen Allele Registry — a CAID resolved to an identity this format can carry (RM153).

A ClinGen allele_registry_id (CA341482) is a build-independent name for an allele. CIViC publishes one for most of the variants whose coordinates it only has on GRCh37, so the registry is the route that places those rows without lifting anything over — which is the whole reason this module exists rather than a liftover.

It returns an rs-number where it can, and a GRCh38 coordinate only otherwise. Both are usable, and the preference is not stylistic. An rsID is build-independent, so a drafted row carrying one is placed by the ordinary resolution chain rather than by us — and the value resolution then verifies came from dbSNP via ClinGen, while the chain that verifies it is Ensembl. Two authorities, so the check is real. That is the property RM48 refused a liftover for lacking: a lifted coordinate is the row's sole identity with nothing independent to check it against, and an rs-number recovered from the same service that will later be asked is the same defect wearing a different hat.

Three outcomes, never two. resolved / no_identity / unchecked. A registry that answers and holds no placeable identity for a CAID (an indel whose alleles it cannot express as a substitution) is a fact about the registry; a request that failed is not. Collapsing them is the S20 defect — ([], None) reading a failed request as an established absence — and it is the reason the outcome is a dataclass rather than an optional tuple.

Terms could not be established, and that is recorded rather than assumed. The registry is run on Baylor's Genboree infrastructure and its own /site/terms answers HTTP 200 with a generic "broken link" page rather than a licence — the same shape as HPO's 404-behind-a-200, established by probe on 2026-08-31. ClinGen's gene-curation surface is CC0, and this is a different surface, so that grant is not evidence about these bytes. Unknown is not permissive: nothing here is redistributed, and reading a public endpoint to place a row is a read rather than an acquisition anyone has gated (@acquisition-gate-is-not-a-read-gate).

ClingenAlleleError

Bases: RuntimeError

The registry could not be consulted in a way the caller must handle.

AlleleIdentity dataclass

AlleleIdentity(
    caid: str,
    outcome: str,
    rsid: str | None = None,
    coordinate: tuple[str, int, str, str] | None = None,
    unanchored: tuple[str, int, str, str] | None = None,
)

What the registry holds for one CAID.

rsid and coordinate are both optional and at least one is set when outcome == "resolved"; coordinate is (chrom, start, ref, alt) with start the 1-based position this format's start column means (@start-1based), converted from the registry's interbase start/end pair.

placeable property

placeable: bool

True when this carries something a VariantRow can be identified by.

unanchored is deliberately not placeable: a half-stated allele is not a position (@identity-whole-or-none), and it becomes one only once anchor_indel has read the base.

ClingenAlleleClient

ClingenAlleleClient(
    *, offline: bool = False, client: Client | None = None
)

Resolve CAIDs, one at a time, paced, with a per-run cache.

A class rather than a function because the pacing gate and the cache are per-run state, and a module-level one would make two callers in one process share a clock they did not agree on.

Source code in enricher/src/just_dna_enricher/clingen_allele.py
def __init__(self, *, offline: bool = False, client: httpx.Client | None = None) -> None:
    self._offline = offline
    self._client = client
    self._gate = PacingGate(_REQUEST_INTERVAL)
    self._cache: dict[str, AlleleIdentity] = {}

resolve

resolve(caid: str) -> AlleleIdentity

One CAID → an identity, an established absence, or nobody-asked.

Source code in enricher/src/just_dna_enricher/clingen_allele.py
def resolve(self, caid: str) -> AlleleIdentity:
    """One CAID → an identity, an established absence, or nobody-asked."""
    caid = (caid or "").strip()
    if not caid:
        return AlleleIdentity(caid=caid, outcome="no_identity")
    if self._offline:
        return AlleleIdentity(caid=caid, outcome="skipped_offline")
    if caid in self._cache:
        return self._cache[caid]
    try:
        payload = self._fetch(caid)
    except httpx.HTTPStatusError as exc:
        # A 404 is the registry answering: it has no such allele. Any other status is a failure to
        # ask, and the two must not collapse.
        if exc.response.status_code == 404:
            result = AlleleIdentity(caid=caid, outcome="no_identity")
        else:
            logger.warning("ClinGen Allele Registry returned %s for %s", exc.response.status_code, caid)
            result = AlleleIdentity(caid=caid, outcome="unchecked")
    except (httpx.HTTPError, ValueError) as exc:
        logger.warning("ClinGen Allele Registry could not be consulted for %s (%s)", caid, exc)
        result = AlleleIdentity(caid=caid, outcome="unchecked")
    else:
        result = _parse(caid, payload)
    self._cache[caid] = result
    return result

anchor_indel

anchor_indel(
    unanchored: tuple[str, int, str, str], read_base
) -> tuple[str, int, str, str] | None

A one-sided indel plus one reference base → a VCF-style row, or None if the base is unknown.

This is VCF's anchored form at the registry's own position, which is not always the left-aligned one (RM273). An insertion and a deletion each state one side of the change and leave the other empty, which no ref/alts pair can hold; anchoring prefixes both sides with the single reference base immediately before the event, so ref and alts are both non-empty and the row means exactly what the registry said. The registry's interbase point follows HGVS's 3′ rule, so inside a repeat the result sits right of VCF's left-aligned spelling (rs72613567: 4:87310241 A>AA, where VCF writes 87310240 T>TA); a caller writing a row left-aligns it after (sequences._left_align):

deletion of `A` after base P   →  POS=P, REF=<base P> + "A", ALT=<base P>
insertion of `G` after base P  →  POS=P, REF=<base P>,       ALT=<base P> + "G"

The registry's interbase start is already that P, for both shapes, which is why one rule covers them and no per-shape arithmetic appears here.

read_base is injected — (chrom, one_based_pos) -> str | None — so this stays a pure function with the network on the outside, and so a test can exercise both shapes without a sequence service. None when the base cannot be read: an unknown anchor is withheld, never guessed, and a guessed anchor would put a wrong ref on a right position, which is the mismatch class sequences.RefMismatch exists to report.

Source code in enricher/src/just_dna_enricher/clingen_allele.py
def anchor_indel(unanchored: tuple[str, int, str, str], read_base) -> tuple[str, int, str, str] | None:
    """A one-sided indel plus one reference base → a VCF-style row, or `None` if the base is unknown.

    **This is VCF's anchored form at the registry's own position, which is not always the left-aligned
    one** (RM273). An insertion and a deletion each state one side of the change and leave the other
    empty, which no `ref`/`alts` pair can hold; anchoring prefixes both sides with the single reference
    base immediately before the event, so `ref` and `alts` are both non-empty and the row means exactly
    what the registry said. The registry's interbase point follows HGVS's 3′ rule, so inside a repeat
    the result sits right of VCF's left-aligned spelling (`rs72613567`: `4:87310241 A>AA`, where VCF
    writes `87310240 T>TA`); a caller writing a row left-aligns it after (`sequences._left_align`):

        deletion of `A` after base P   →  POS=P, REF=<base P> + "A", ALT=<base P>
        insertion of `G` after base P  →  POS=P, REF=<base P>,       ALT=<base P> + "G"

    The registry's interbase `start` is already that P, for both shapes, which is why one rule covers
    them and no per-shape arithmetic appears here.

    `read_base` is injected — `(chrom, one_based_pos) -> str | None` — so this stays a pure function
    with the network on the outside, and so a test can exercise both shapes without a sequence
    service. `None` when the base cannot be read: an unknown anchor is withheld, never guessed, and a
    guessed anchor would put a wrong `ref` on a right position, which is the mismatch class
    `sequences.RefMismatch` exists to report.
    """
    chrom, pos, ref, alt = unanchored
    if pos < 1:
        # An event at the very start of a contig has no preceding base to anchor on. Rare enough to be
        # theoretical and cheap enough to refuse outright rather than special-case.
        return None
    base = read_base(chrom, pos)
    if not base:
        return None
    base = base.upper()
    return (chrom, pos, base + ref, base + alt)