Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions @types/bionym-components.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// bionym-components ships plain JavaScript with no types. These cover the
// parts the gene page's graph explorer uses; see the package's README.
declare module "bionym-components" {
export interface BionymComponentsNode {
id: string;
label: string;
type: string;
url: string | null;
}

export interface BionymComponentsEdge {
confidence: number;
object: string;
predicate: string;
subject: string;
}

export interface BionymComponentsGraph {
edges: BionymComponentsEdge[];
metadata: { query: string };
nodes: BionymComponentsNode[];
}

export type BionymNetworkLayout = "columns" | "force";

export interface BionymNetworkOptions {
layout?: BionymNetworkLayout;
minConfidence?: number;
onSelect?: (node: BionymComponentsNode | null) => void;
}

export interface BionymNetworkInstance {
destroy(): void;
fit(): void;
setMinConfidence(minConfidence: number): void;
zoomBy(factor: number): void;
}

export function BionymNetwork(
el: HTMLElement,
graph: BionymComponentsGraph,
opts?: BionymNetworkOptions
): BionymNetworkInstance;

export interface BionymOverviewOptions {
onBarClick?: (nodeType: string) => void;
}

export interface BionymOverviewInstance {
destroy(): void;
}

export function BionymOverview(
el: HTMLElement,
graph: BionymComponentsGraph,
opts?: BionymOverviewOptions
): BionymOverviewInstance;
}
17 changes: 16 additions & 1 deletion backend/api/app/api/v1/genes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from fastapi import APIRouter, Depends, HTTPException

from app.core.dependencies import get_bionym_graphs, get_gene_catalog_service
from app.models.genes import BionymClaims, GeneResponse
from app.models.genes import BionymClaims, BionymGraph, GeneResponse
from app.services.bionym_graphs import BionymGraphStore
from app.services.gene_catalog import GeneCatalogService

Expand Down Expand Up @@ -42,3 +42,18 @@ def get_gene_bionym(
status_code=404, detail=f"Gene {gene_uid} has no BioNym graph"
)
return claims


@router.get("/{gene_uid}/bionym/graph", response_model=BionymGraph)
def get_gene_bionym_graph(
gene_uid: UUID,
graphs: BionymGraphStore = Depends(get_bionym_graphs),
) -> BionymGraph:
"""Every node and edge in the gene's BioNym graph, for the gene page's
graph explorer. Like the claims, read from the graph alone."""
graph = graphs.get_graph(gene_uid)
if graph is None:
raise HTTPException(
status_code=404, detail=f"Gene {gene_uid} has no BioNym graph"
)
return graph
32 changes: 32 additions & 0 deletions backend/api/app/models/genes.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,38 @@ class BionymSource(_CamelModel):
claims: List[BionymClaim]


class BionymGraphNode(_CamelModel):
"""A node in a gene's BioNym graph: a gene, protein, paper, dataset,
claim or another kind of thing BioNym found."""

id: str
type: str
label: str
# Null unless the graph gives an https: link.
url: Optional[str]


class BionymGraphEdge(_CamelModel):
"""An edge in a gene's BioNym graph, with BioNym's confidence in it,
from 0 to 1."""

subject: str
predicate: str
object: str
confidence: float


class BionymGraph(_CamelModel):
"""A gene's BioNym graph: every node and edge, with only the fields the
gene page's graph explorer draws."""

gene_uid: UUID
# The id of the gene's own node, the graph's root.
query: str
nodes: List[BionymGraphNode]
edges: List[BionymGraphEdge]


class BionymClaims(_CamelModel):
"""The claims in a gene's BioNym graph, grouped by source."""

Expand Down
60 changes: 55 additions & 5 deletions backend/api/app/services/bionym_graphs.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""BioNym's claims about a gene, read from the gene's BioNym graph.
"""BioNym's claims about a gene, and the graph itself, read from the gene's
BioNym graph.

A BioNym graph is BioNym's JSON output for one gene: nodes, edges between
them, and metadata about the run. Each claim is a Claim node that a
Publication or Dataset node `reports`; the edge holds BioNym's confidence in
the claim, and its one piece of evidence holds the quote the claim rests on.
The graph view keeps every node and edge, but only the fields the gene
page's graph explorer draws.

The graphs are checked in, gzipped and named by gene UID, under
app/data/bionym (see its README) until they are published to the gene data
Expand All @@ -21,7 +24,14 @@

from pydantic import BaseModel

from app.models.genes import BionymClaim, BionymClaims, BionymSource
from app.models.genes import (
BionymClaim,
BionymClaims,
BionymGraph,
BionymGraphEdge,
BionymGraphNode,
BionymSource,
)

BIONYM_GRAPHS_DIR = Path(__file__).resolve().parent.parent / "data" / "bionym"

Expand Down Expand Up @@ -53,6 +63,7 @@ class _Edge(BaseModel):

class _Metadata(BaseModel):
input: str
query: str


class _Graph(BaseModel):
Expand Down Expand Up @@ -124,22 +135,61 @@ def build_claims(gene_uid: UUID, graph: _Graph) -> BionymClaims:
)


def build_graph(gene_uid: UUID, graph: _Graph) -> BionymGraph:
"""Every node and edge in the graph, in BioNym's order, with only the
fields the graph explorer draws and only https: links."""
if graph.metadata.query not in graph.nodes:
raise ValueError(f"the graph's query {graph.metadata.query!r} isn't a node")
for edge in graph.edges:
for end in (edge.subject, edge.object):
if end not in graph.nodes:
raise ValueError(
f"an edge {edge.predicate!r} ends at {end!r}, not a node"
)
return BionymGraph(
gene_uid=gene_uid,
query=graph.metadata.query,
nodes=[
BionymGraphNode(
id=node_id, type=node.type, label=node.label, url=_https_url(node.url)
)
for node_id, node in graph.nodes.items()
],
edges=[
BionymGraphEdge(
subject=edge.subject,
predicate=edge.predicate,
object=edge.object,
confidence=edge.confidence,
)
for edge in graph.edges
],
)


class BionymGraphStore:
"""The BioNym graphs in a directory, each named <gene_uid>.json.gz.

Every graph's claims are built when the store is made: on the first
BioNym request, and in the tests over every checked-in graph, so a graph
of the wrong shape fails the tests rather than reaching a gene's page.
Every graph's claims and graph view are built when the store is made: on
the first BioNym request, and in the tests over every checked-in graph, so
a graph of the wrong shape fails the tests rather than reaching a gene's
page.
"""

def __init__(self, directory: Path):
self._claims: Dict[UUID, BionymClaims] = {}
self._graphs: Dict[UUID, BionymGraph] = {}
for path in directory.glob("*.json.gz"):
gene_uid = UUID(path.name.removesuffix(".json.gz"))
with gzip.open(path, "rb") as f:
graph = _Graph.model_validate_json(f.read())
self._claims[gene_uid] = build_claims(gene_uid, graph)
self._graphs[gene_uid] = build_graph(gene_uid, graph)

def get_claims(self, gene_uid: UUID) -> Optional[BionymClaims]:
"""The gene's claims, or None if it has no graph."""
return self._claims.get(gene_uid)

def get_graph(self, gene_uid: UUID) -> Optional[BionymGraph]:
"""The gene's graph view, or None if it has no graph."""
return self._graphs.get(gene_uid)
87 changes: 82 additions & 5 deletions backend/api/tests/test_genes_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,10 @@ def test_transcripts_are_in_genome_order(client, gene_uid):
def write_graph(directory, gene_uid: str, sources: dict) -> None:
"""Write a BioNym graph in which each source reports one claim.

sources maps a source's node ID to its node type and URL.
sources maps a source's node ID to its node type and URL. The graph's
query is a gene node, PF3D7_0000000, with no edges.
"""
nodes = {}
nodes = {"PF3D7_0000000": {"type": "Gene", "label": "PF3D7_0000000", "url": None}}
edges = []
for i, (source_id, (node_type, url)) in enumerate(sources.items()):
claim_id = f"claim:claim {i}"
Expand All @@ -302,7 +303,11 @@ def write_graph(directory, gene_uid: str, sources: dict) -> None:
],
}
)
graph = {"nodes": nodes, "edges": edges, "metadata": {"input": "PF3D7_0000000"}}
graph = {
"nodes": nodes,
"edges": edges,
"metadata": {"input": "PF3D7_0000000", "query": "PF3D7_0000000"},
}
with gzip.open(directory / f"{gene_uid}.json.gz", "wt") as f:
json.dump(graph, f)

Expand Down Expand Up @@ -354,9 +359,38 @@ def test_every_checked_in_bionym_graph_is_read(checked_in_graphs, gene_uid, locu
assert {s.kind for s in claims.sources} == {"paper", "dataset"}


def test_gene_without_a_bionym_graph_is_404(tmp_path):
def test_bionym_graph(checked_in_graphs):
response = make_client(graphs=checked_in_graphs).get(
f"/api/v1/genes/{KELCH13_UID}/bionym/graph"
)
assert response.status_code == 200
body = response.json()
assert body["geneUid"] == KELCH13_UID
# The whole graph, ortholog-group members included.
assert len(body["nodes"]) == 5239
assert len(body["edges"]) == 6536
assert sum(e["predicate"] == "has_member" for e in body["edges"]) == 2460
[root] = [n for n in body["nodes"] if n["id"] == body["query"]]
assert (root["id"], root["type"]) == ("PF3D7_1343700", "Gene")
# Only the fields the explorer draws.
assert set(root) == {"id", "type", "label", "url"}
assert set(body["edges"][0]) == {"subject", "predicate", "object", "confidence"}


@pytest.mark.parametrize("gene_uid, locus_tag", BIONYM_GENES.items())
def test_every_checked_in_bionym_graph_has_its_gene_as_root(
checked_in_graphs, gene_uid, locus_tag
):
graph = checked_in_graphs.get_graph(uuid.UUID(gene_uid))
assert graph is not None
assert graph.query == locus_tag
assert {n.type for n in graph.nodes if n.id == locus_tag} == {"Gene"}


@pytest.mark.parametrize("path", ["bionym", "bionym/graph"])
def test_gene_without_a_bionym_graph_is_404(tmp_path, path):
response = make_client(graphs=BionymGraphStore(tmp_path)).get(
f"/api/v1/genes/{uuid.uuid4()}/bionym"
f"/api/v1/genes/{uuid.uuid4()}/{path}"
)
assert response.status_code == 404

Expand Down Expand Up @@ -391,10 +425,53 @@ def test_bionym_links_other_than_https_are_dropped(tmp_path):
"dataset:3": ("dataset", None),
"dataset:4": ("dataset", None),
}
nodes = (
make_client(graphs=BionymGraphStore(tmp_path))
.get(f"/api/v1/genes/{uid}/bionym/graph")
.json()["nodes"]
)
assert {n["id"]: n["url"] for n in nodes if n["type"] != "Claim"} == {
"PF3D7_0000000": None,
"pubmed:1": "https://pubmed.ncbi.nlm.nih.gov/1/",
"pubmed:2": None,
"dataset:3": None,
"dataset:4": None,
}


def test_bionym_claim_from_a_node_that_is_not_a_source_is_an_error(tmp_path):
uid = uuid.uuid4()
write_graph(tmp_path, str(uid), {"assembly:1": ("Assembly", None)})
with pytest.raises(ValueError, match="isn't a source"):
BionymGraphStore(tmp_path)


def _rewrite_graph(directory, gene_uid: str, change) -> None:
path = directory / f"{gene_uid}.json.gz"
with gzip.open(path, "rt") as f:
graph = json.load(f)
change(graph)
with gzip.open(path, "wt") as f:
json.dump(graph, f)


def test_bionym_graph_whose_query_is_not_a_node_is_an_error(tmp_path):
uid = str(uuid.uuid4())
write_graph(tmp_path, uid, {"pubmed:1": ("Publication", None)})
_rewrite_graph(tmp_path, uid, lambda g: g["metadata"].update(query="PF3D7_9999999"))
with pytest.raises(ValueError, match="isn't a node"):
BionymGraphStore(tmp_path)


def test_bionym_graph_edge_to_a_missing_node_is_an_error(tmp_path):
uid = str(uuid.uuid4())
write_graph(tmp_path, uid, {"pubmed:1": ("Publication", None)})
_rewrite_graph(
tmp_path,
uid,
lambda g: g["edges"].append(
{**g["edges"][0], "predicate": "cited_in", "object": "pubmed:missing"}
),
)
with pytest.raises(ValueError, match="not a node"):
BionymGraphStore(tmp_path)
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13",
"bionym-components": "0.1.1",
"copy-to-clipboard": "^3.3.3",
"d3-color": "^3.1.0",
"d3-hierarchy": "^3.1.2",
Expand Down
Loading
Loading