Imported from SACGF/variantgrid (
genes/AGENTS.md). Install upstream withnpx skills add SACGF/variantgrid --skill genes. Copyright stays with the author.
genes — agent notes
Owns: Gene/GeneVersion, GeneSymbol/GeneSymbolAlias, HGNC, Transcript/TranscriptVersion (cdot data), GeneAnnotationRelease and per-release symbol→gene matching, HGVS parsing and resolution (biocommons + ClinGen fallback), LRG, MANE, canonical transcripts, gene lists (custom text, sample, PanelApp cache), gene coverage, gene fusions, gnomAD constraint, Pfam/UniProt. Start with:
- models/models_gene.py — GeneSymbol, Gene, GeneVersion, Transcript, TranscriptVersion, TranscriptVersionSequenceInfo, LRGRefSeqGene, HGNC, MANE. models/init.py star-imports every models_*.py, so
from genes.models import Xstill works. - models/models_gene_annotation_release.py — GeneAnnotationRelease, ReleaseGeneVersion/ReleaseTranscriptVersion, ReleaseGeneSymbol/ReleaseGeneSymbolGene
- models/models_gene_list.py — GeneList, GeneListGeneSymbol, GeneListCategory, CustomTextGeneList, SampleGeneList/ActiveSampleGeneList
- hgvs/hgvs_matcher.py — HGVSMatcher (string → VariantCoordinate and back); hgvs/hgvs.py — HGVSComponents (lenient string parsing, no DB)
- gene_matching.py — GeneSymbolMatcher (text → GeneListGeneSymbol), ReleaseGeneMatcher (symbol → genes per release), HGNCMatcher
- management/commands/import_cdot_latest.py wraps management/commands/import_gene_annotation.py (Gene/Transcript versions from cdot JSON); management/commands/import_cdot_gene_annotation_release.py then creates the GeneAnnotationRelease a VEP build uses. Patterns here:
- Gene and Transcript are stable build-independent ids; GeneVersion and TranscriptVersion carry the build and are unique on (id, version, genome_build) (models/models_gene.py:GeneVersion, models/models_gene.py:TranscriptVersion). Always filter versions by genome_build.
- The symbol lives on GeneVersion, not Gene: use
Gene.get_gene_symbol(genome_build)and expectGene.get_symbols()to return several over time (models/models_gene.py:Gene). - Resolve a string to a GeneSymbol with models/models_gene.py:GeneSymbol.cast (cached); resolve aliases and bulk text with gene_matching.py:GeneSymbolMatcher, which also writes the per-release ReleaseGeneSymbolGene rows.
- Symbol→gene lookups are per release: use
GeneAnnotationRelease.genes_for_symbol()/GeneList.get_genes(release)(models/models_gene_annotation_release.py:GeneAnnotationRelease, models/models_gene_list.py:GeneList.get_genes). Take the release fromVariantAnnotationVersion.gene_annotation_release, never "latest". - Transcript geometry and tags come from cdot JSON in
TranscriptVersion.data["genome_builds"][build]; read tags via models/models_gene.py:TranscriptVersion.tags and canonical-ness via models/models_gene.py:TranscriptVersion.CANONICAL_SCORES (MANE Select 2, RefSeq Select 1; "basic" is stripped, not scored). - Get an HGVSMatcher with hgvs/hgvs_matcher.py:HGVSMatcher.instance (lru-cached per build; construction opens the genome fasta). Pass
clingen_resolution=Falsein batch/offline code. - hgvs/hgvs_matcher.py:HGVSMatcher.get_variant_coordinate_and_details tries local biocommons first, then the ClinGen Allele Registry, across neighbouring transcript versions (hgvs/hgvs_matcher.py:HGVSMatcher.filter_best_transcripts_and_converter_type_by_accession); version-distance ranking is delegated to the external cdot package, so change ranking there.
- Symbol-only HGVS ("BRCA1:c.100A>G") is a search feature, not a matcher feature: snpdb/signals/variant_search.py ranks transcripts with hgvs/hgvs_matcher.py:HGVSMatcher.rank_gene_symbol_transcripts under the
SEARCH_HGVS_GENE_SYMBOL*settings; the matcher itself raises on a transcript-less c.HGVS. - Parse or rewrite an HGVS string without touching the DB using hgvs/hgvs.py:HGVSComponents; use HGVSMatcher only when you need coordinates.
- Consortium is the single-letter
AnnotationConsortium(R/E) on Gene, Transcript, GeneAnnotationRelease and CanonicalTranscriptCollection (models_enums.py:AnnotationConsortium); infer it from an accession withAnnotationConsortium.get_from_transcript_accession(). - Reference data (HGNC, MANE, LRG, Pfam, gnomAD constraint, PanelApp panel lists) loads through CachedWebResource tasks (tasks/cached_web_resource_tasks.py:HGNCWebResourceTask, cached_web_resource/hgnc.py:store_hgnc_from_web), not management commands. Gotchas:
- "Canonical" means three different things: VEP's per-annotation flag (models/models_gene.py:Gene.get_vep_canonical_transcript), cdot MANE/RefSeq Select tags (TranscriptVersion.canonical_score), and CanonicalTranscriptCollection, a per-enrichment-kit list used for coverage whose default is
settings.GENES_DEFAULT_CANONICAL_TRANSCRIPT_COLLECTION_ID(canonical_transcripts/canonical_transcript_manager.py:CanonicalTranscriptManager). Name which one you mean. GeneSymbol.symbolis a case-insensitive collation PK:symbol__startswithraises NotSupportedError; use models/models_gene.py:GeneSymbol.get_deterministic_queryset and filter onsymbol_deterministic.- models/models_gene.py:TranscriptVersion.get_transcript_version with
best_attempt=True(the default) silently returns the nearest higher (or highest) version when the requested one is missing; passbest_attempt=Falsewhen the exact version matters. - A TranscriptVersion with
data == {}or missing build data is legacy: check models/models_gene.py:TranscriptVersion.hgvs_ok before HGVS work — the matcher skips those and falls back to ClinGen via a hgvs/hgvs_matcher.py:FakeTranscriptVersion. - LRG c.HGVS is rewritten to the mapped RefSeq accession via models/models_gene.py:LRGRefSeqGene before resolution; models/models_gene.py:TranscriptVersion.get_for_lrg only knows LRG_199t1 and is not the general path.
- Genes whose id starts with
Gene.FAKE_GENE_ID_PREFIX("unknown_") are legacy placeholders (models/models_gene.py:Gene); hide them from users and expect them to lack versions. - A GeneList's symbols only reach analyses once matched into every release (gene_matching.py:ReleaseGeneMatcher); after inserting GeneListGeneSymbol rows outside GeneSymbolMatcher, run
match_unmatched_in_hgnc_and_gene_lists()ormanage.py rematch_unmatched_gene_list_symbols. - Gene overlaps for an interval live in gene_overlaps.py:SVGeneOverlapResolver (built from a GeneAnnotationRelease, or
for_variant_annotation_version), not in the annotation inserter that used to own them —genescan't import that module at all (it pulls inupload). Its IntervalTrees are built per contig on first use, so asking about a handful of positions doesn't load a whole ~177k-transcript release. - A fusion side resolves by breakpoint first and name second (gene_fusions.py:GeneFusionResolver.resolve_side), and the genes it lands in are recorded on models/models_gene_level.py:GeneLevelId.genes — annotation reads those before the symbol. gene_level_resolver.py:GeneLevelNameResolver.resolve_name also consults HGNC's own previous/alias symbols, because a GeneSymbol row for the old name (Ensembl still writes ACPP for ACP3) stops GeneSymbolMatcher ever reaching GeneSymbolAlias.
- Name-to-identity resolution is shared by both kinds of gene-level event: gene_level_resolver.py:GeneLevelNameResolver turns a caller's cell into a models/models_gene_level.py:GeneLevelId, gene_fusions.py:GeneFusionResolver adds the breakpoint half, and gene_copy_number.py resolves the one gene a whole-gene copy number call names. 'EGFR amplification' is what is written out; 'amp', 'gain', 'deletion' and 'del' are accepted on input (gene_copy_number.py:COPY_NUMBER_KIND_WORDS).
- Re-running gene-level annotation needs no special code: delete the GENE_LEVEL AnnotationRuns and their VariantAnnotation/VariantTranscriptAnnotation/VariantGeneOverlap rows cascade with them, then annotation/tasks/annotation_scheduler_task.py:_handle_variant_annotation_version recreates a run for every lock missing one (the #720 orphan scan, also how a newly-enabled pipeline backfills). That fixes annotation, not identity — a GeneLevelId with no hgnc and no genes still resolves through a symbol no release carries, so for identities minted before resolution improved, re-load the caller's file: there is no backfill command.
- A splice call is the third kind of gene-level event and the only one with no record of its own: gene_splice.py reads
it straight off the alt, which carries the gene and the junction's label (
<SPLICE:HGNC:644:V_7>). The lab's label is the identity, the way a fusion's is its two symbols: a splice string goes through the stages an HGVS does and mints its Variant whenever it validates, whether or not anything has been observed under that name (#1835). Duplicates are prevented by canonicalisation, not by a table - gene_splice.py:canonical_splice_label turns every written form of one junction (AR-V7,ARV7,AR-V7 splice variant) into one label, lower-case tokens joined by underscores (v_7,v_iii,v_iva,exon_14_skipping), and gene_splice.py:display_splice_label writes it back out (AR-V7,EGFRvIVa). A junction named by its breakpoints carries the build, since a gene-level Variant sits on the contig every build shares (grch37_x_66905968_66914514->AR GRCh37 X:66905968-66914514). models/models_splice_event.py:SpliceEvent has one job left: gene_splice.py:SpliceEventResolver turns the TSO 500 caller's breakpoints into the label a classification for the same junction arrives under (seeded in genes/migrations/0093_seed_splice_events.py, canonicalised in 0095), and itsdisplayis the wording a report gets. It is never consulted on the classification path - gene_splice.py:resolve_splice_string for the string-in path, gene_splice.py:find_splice_events_for_string for the lookup-only one search uses. The label on the alt is upper-case (<SPLICE:HGNC:7029:EXON_14_SKIPPING>) because the alt is a Sequence and every path that inserts one upper-cases it (library/genomics/vcf_enums.py:GeneLevelSymbolicAlt.format); that is the storage form only, andparselowers it back to the canonical label. - Which kind of gene-level event a written value is, and whether it is one at all, is gene_level_strings.py:looks_gene_level
and gene_level_strings.py:resolve_gene_level_string - the only module that knows all three kinds. Each
resolve_*_stringanswers with a gene_level_resolver.py:GeneLevelResolution: the identity, or the reason the kind that recognised the string refused it (a typo'd fusion partner), which is what the record's message and itsgene_level_unresolvedvalidation tag say. A resolution is falsy until it resolved, so test.recognised/.reasonrather thanor-ing one. - models/models_gene_list.py:GeneList.get_q imports from annotation inside the method — the genes/annotation import cycle is real; keep new cross-imports out of module level.
- Creating a second SampleGeneList for a sample deletes the ActiveSampleGeneList instead of switching it (models/models_gene_list.py:sample_gene_list_created); set the active one explicitly.
PanelAppPanel.cache_validexpires aftersettings.PANEL_APP_CACHE_DAYS(models/models_panel_app.py:PanelAppPanel.cache_valid); panel_app.py:get_panel_app_local_cache re-fetches from the live API when stale, so tests must not depend on it.- GeneCoverageCollection is a partitioned model (models/models_gene_coverage.py:GeneCoverageCollection); delete via the model so partitions are dropped.
- gene_matching.py:GeneSymbolMatcher and gene_matching.py:ReleaseGeneMatcher cache whole-table dicts on first use; build one per import, not per symbol.
- gene_matching.py:ReleaseGeneMatcher takes exactly one GeneSymbolAlias hop (either direction) - chaining hops lets an alias string shared by two unrelated genes bridge them (#1669). Keep it single-hop.
- Matching only ever inserts ReleaseGeneSymbolGene rows, so a rematch can't remove a match that's since become wrong;
manage.py fix_rematch_release_symbols_to_genes(--dry-runfirst) is the full resync that also updates and deletes. - GeneGrid's gene/disease (GenCC) column asks for relations by symbol, and a symbol with no HGNC record yields empty relations rather than an error from both traversers (
ontology/models/models_ontology.py:OntologySnake.terms_for_gene_symbolandontology/ontology_traversal.py:MemoryOntologyTraverser), so column and page code needs no guard. <CNV>and<INS>have no HGVS at all - neither a ranged form nor an explicit expansion - so hgvs/hgvs_matcher.py:HGVSMatcher raises hgvs/hgvs_converter.py:HGVSNoRepresentationException before any converter runs, and classification records it asResolvedVariantInfo.errorrather than a Rollbar bug. Tests:- annotation/tests/test_data_fake_genes.py:create_fake_transcript_version builds Gene/GeneVersion/Transcript/TranscriptVersion (RUNX1, ENST00000300305.7) for a build;
create_gata2_transcript_version/create_pten_transcript_versionadd RefSeq examples. - Pair those with annotation/fake_annotation.py:get_fake_annotation_version, which creates the GeneAnnotationRelease and VariantAnnotationVersion that release-scoped code needs.
- Transcript sequence fetches are mocked for the whole suite by variantgrid/test_runner.py setting
TranscriptSequenceFetcher.override_classto tests/utils/mock_transcript_sequence_retrieval.py:MockTranscriptSequenceFetcher — add new accessions to the fasta files in tests/test_data rather than hitting NCBI/Ensembl. - tests/test_urls.py:Test is the URLTestCase for every genes page (owner vs non-owner permission checks included).
- tests/test_hgvs_corpus.py:HGVSCorpusTests runs tests/test_data/hgvs_corpus.tsv (400+ strings, malformed on purpose) through HGVSComponents; add new edge cases there.
- tests/test_hgvs.py needs the fake annotation version and a genome fasta for coordinate resolution, and is the slow one. Deep reference: claude/research/genes.md · claude/maps/models.md#genes