Skip to content

Commit f17b845

Browse files
authored
Endpoint for Y chromosome clade information (#678)
* Initial implementation of Y-DNA endpoint * Complete test, add docs * Add yclade version info to metadata * Return Y tree version * Cache YFull tree in docker image
1 parent 8d0cc95 commit f17b845

File tree

7 files changed

+318
-0
lines changed

7 files changed

+318
-0
lines changed

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ RUN ARCH=$(uname -m) && \
9797
model = SentenceTransformer('sentence-transformers/distiluse-base-multilingual-cased-v2')"; \
9898
fi
9999

100+
# download and cache YFull tree for yclade
101+
RUN python3 -c "import yclade; yclade.tree.download_yfull_tree()"
102+
100103
EXPOSE 5000
101104

102105
COPY docker-entrypoint.sh /

gramps_webapi/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
UsersResource,
121121
UserTriggerResetPasswordResource,
122122
)
123+
from .resources.ydna import PersonYDnaResource
123124
from .util import get_db_handle, get_tree_from_jwt, use_args
124125

125126
api_blueprint = Blueprint("api", __name__, url_prefix=API_PREFIX)
@@ -157,6 +158,7 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
157158
"/people/<string:handle>/dna/matches",
158159
"person-dna-matches",
159160
)
161+
register_endpt(PersonYDnaResource, "/people/<string:handle>/ydna", "person-ydna")
160162
register_endpt(PeopleResource, "/people/", "people")
161163
# Families
162164
register_endpt(

gramps_webapi/api/resources/metadata.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
"""Metadata API resource."""
2222

23+
from importlib import metadata
24+
2325
import gramps_ql as gql
2426
import object_ql as oql
2527
import pytesseract
@@ -125,6 +127,7 @@ def get(self, args) -> Response:
125127
},
126128
"gramps_ql": {"version": gql.__version__},
127129
"object_ql": {"version": oql.__version__},
130+
"yclade": {"version": metadata.version("yclade")},
128131
"locale": {
129132
"lang": GRAMPS_LOCALE.lang,
130133
"language": GRAMPS_LOCALE.language[0],
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#
2+
# Gramps Web API - A RESTful API for the Gramps genealogy program
3+
#
4+
# Copyright (C) 2025 David Straub
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as published by
8+
# the Free Software Foundation; either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU Affero General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Affero General Public License
17+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
"""Y-DNA resources."""
21+
22+
from __future__ import annotations
23+
from dataclasses import asdict
24+
25+
import yclade
26+
from gramps.gen.errors import HandleError
27+
from gramps.gen.lib import Person
28+
from webargs import fields, validate
29+
30+
from ..cache import request_cache_decorator
31+
from ..util import get_db_handle, use_args, abort_with_message
32+
from . import ProtectedResource
33+
34+
35+
class PersonYDnaResource(ProtectedResource):
36+
"""Resource for getting Y-DNA data for a person."""
37+
38+
@use_args(
39+
{
40+
"locale": fields.Str(
41+
load_default=None, validate=validate.Length(min=2, max=5)
42+
),
43+
"raw": fields.Bool(load_default=False),
44+
},
45+
location="query",
46+
)
47+
@request_cache_decorator
48+
def get(self, args: dict, handle: str):
49+
"""Get Y-DNA data.
50+
51+
The raw data is expected to be in a person attribute of type 'Y-DNA'
52+
in a format the yclade library understands.
53+
"""
54+
db_handle = get_db_handle()
55+
try:
56+
person: Person | None = db_handle.get_person_from_handle(handle)
57+
except HandleError:
58+
abort_with_message(404, "Person not found")
59+
if person is None:
60+
abort_with_message(404, "Person not found")
61+
raise AssertionError # for type checker
62+
attribute = next(
63+
(attr for attr in person.attribute_list if attr.type == "Y-DNA"), None
64+
)
65+
if attribute is None:
66+
return {}
67+
snp_string = attribute.value
68+
snp_results = yclade.snps.parse_snp_results(snp_string)
69+
tree_data = yclade.tree.get_yfull_tree_data()
70+
snp_results = yclade.snps.normalize_snp_results(
71+
snp_results=snp_results,
72+
snp_aliases=tree_data.snp_aliases,
73+
)
74+
ordered_clade_details = yclade.find.get_ordered_clade_details(
75+
tree=tree_data, snps=snp_results
76+
)
77+
if len(ordered_clade_details) == 0:
78+
return {}
79+
most_likely_clade = ordered_clade_details[0].name
80+
clade_lineage = yclade.find.get_clade_lineage(
81+
tree=tree_data, node=most_likely_clade
82+
)
83+
result = {
84+
"clade_lineage": [asdict(clade_info) for clade_info in clade_lineage],
85+
"tree_version": tree_data.version,
86+
}
87+
if args["raw"]:
88+
result["raw_data"] = snp_string
89+
return result

gramps_webapi/data/apispec.yaml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,47 @@ paths:
13291329
description: "Unprocessable Entity: Invalid or bad parameter provided."
13301330

13311331

1332+
/people/{handle}/dna/ydna:
1333+
get:
1334+
tags:
1335+
- people
1336+
summary: Get Y-DNA clade lineage for a person
1337+
description: >
1338+
Returns the Y-DNA clade lineage for the person with the given handle, including clade age and confidence intervals if available.
1339+
parameters:
1340+
- name: handle
1341+
in: path
1342+
required: true
1343+
type: string
1344+
description: The handle of the person.
1345+
- name: raw
1346+
in: query
1347+
required: false
1348+
type: boolean
1349+
description: If true, include raw data in the response.
1350+
responses:
1351+
200:
1352+
description: Successful response with clade lineage information.
1353+
schema:
1354+
type: object
1355+
properties:
1356+
clade_lineage:
1357+
type: array
1358+
items:
1359+
$ref: '#/definitions/CladeInfo'
1360+
raw_data:
1361+
description: The raw Y chromosome SNP data in a format compatible with the yclade library.
1362+
type: string
1363+
example: M215+, BY61636-, FTF15749-, TY15744-
1364+
401:
1365+
description: "Unauthorized: Missing authorization header."
1366+
404:
1367+
description: "Person not found."
1368+
422:
1369+
description: "Unprocessable Entity: Invalid or bad parameter provided."
1370+
1371+
1372+
13321373
##############################################################################
13331374
# Endpoint - Families
13341375
##############################################################################
@@ -7512,6 +7553,66 @@ paths:
75127553

75137554
definitions:
75147555

7556+
##############################################################################
7557+
# Model - CladeAgeInfo
7558+
##############################################################################
7559+
7560+
CladeAgeInfo:
7561+
type: object
7562+
properties:
7563+
formed:
7564+
type: number
7565+
format: float
7566+
description: How many years ago the clade was formed.
7567+
nullable: true
7568+
example: 4500
7569+
formed_confidence_interval:
7570+
type: array
7571+
items:
7572+
type: number
7573+
format: float
7574+
minItems: 2
7575+
maxItems: 2
7576+
description: 95% confidence interval for the formed age.
7577+
nullable: true
7578+
most_recent_common_ancestor:
7579+
type: number
7580+
format: float
7581+
description: How many years ago the most recent common ancestor was born.
7582+
nullable: true
7583+
example: 3800
7584+
most_recent_common_ancestor_confidence_interval:
7585+
type: array
7586+
items:
7587+
type: number
7588+
format: float
7589+
minItems: 2
7590+
maxItems: 2
7591+
description: 95% confidence interval for the most recent common ancestor age.
7592+
nullable: true
7593+
7594+
##############################################################################
7595+
# Model - CladeInfo
7596+
##############################################################################
7597+
7598+
CladeInfo:
7599+
type: object
7600+
properties:
7601+
name:
7602+
type: string
7603+
description: The ID of the clade.
7604+
example: BY61636
7605+
age_info:
7606+
$ref: '#/definitions/CladeAgeInfo'
7607+
description: The age information for the clade.
7608+
nullable: true
7609+
score:
7610+
type: number
7611+
format: float
7612+
description: The score for the clade (optional).
7613+
nullable: true
7614+
example: 9.0
7615+
75157616
##############################################################################
75167617
# Model - Token
75177618
##############################################################################

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dependencies = [
4444
"object-ql>=0.1.3",
4545
"sifts>=0.8.3",
4646
"requests",
47+
"yclade>=0.5.0",
4748
]
4849

4950
[project.optional-dependencies]

tests/test_endpoints/test_ydna.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#
2+
# Gramps Web API - A RESTful API for the Gramps genealogy program
3+
#
4+
# Copyright (C) 2025 David Straub
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as published by
8+
# the Free Software Foundation; either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU Affero General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Affero General Public License
17+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
"""Tests for the /people/<handle>/ydna/ endpoint."""
21+
22+
import os
23+
import unittest
24+
import uuid
25+
from unittest.mock import patch
26+
27+
from gramps.cli.clidbman import CLIDbManager
28+
from gramps.gen.dbstate import DbState
29+
30+
from gramps_webapi.app import create_app
31+
from gramps_webapi.auth import add_user, user_db
32+
from gramps_webapi.auth.const import ROLE_GUEST, ROLE_OWNER
33+
from gramps_webapi.const import ENV_CONFIG_FILE, TEST_AUTH_CONFIG
34+
35+
36+
def get_headers(client, user: str, password: str):
37+
rv = client.post("/api/token/", json={"username": user, "password": password})
38+
access_token = rv.json["access_token"]
39+
return {"Authorization": f"Bearer {access_token}"}
40+
41+
42+
def make_handle():
43+
return str(uuid.uuid4())
44+
45+
46+
class TestYDnaEndpoint(unittest.TestCase):
47+
@classmethod
48+
def setUpClass(cls):
49+
cls.name = "Test Web API YDNA"
50+
cls.dbman = CLIDbManager(DbState())
51+
dbpath, _ = cls.dbman.create_new_db_cli(cls.name, dbid="sqlite")
52+
tree = os.path.basename(dbpath)
53+
with patch.dict("os.environ", {ENV_CONFIG_FILE: TEST_AUTH_CONFIG}):
54+
cls.app = create_app(config_from_env=False)
55+
cls.app.config["TESTING"] = True
56+
cls.client = cls.app.test_client()
57+
with cls.app.app_context():
58+
user_db.create_all()
59+
add_user(name="user", password="123", role=ROLE_GUEST, tree=tree)
60+
add_user(name="admin", password="123", role=ROLE_OWNER, tree=tree)
61+
62+
@classmethod
63+
def tearDownClass(cls):
64+
cls.dbman.remove_database(cls.name)
65+
66+
def test_without_token(self):
67+
rv = self.client.get("/api/people/nope/ydna")
68+
assert rv.status_code == 401
69+
70+
def test_person_not_found(self):
71+
headers = get_headers(self.client, "user", "123")
72+
rv = self.client.get("/api/people/nope/ydna", headers=headers)
73+
assert rv.status_code == 404
74+
75+
def test_no_ydna(self):
76+
headers = get_headers(self.client, "admin", "123")
77+
person = {
78+
"primary_name": {
79+
"surname_list": [{"_class": "Surname", "surname": "Doe"}],
80+
"first_name": "John",
81+
},
82+
"gender": 1,
83+
}
84+
rv = self.client.post("/api/people/", json=person, headers=headers)
85+
assert rv.status_code == 201
86+
handle = rv.json[0]["handle"]
87+
rv = self.client.get(f"/api/people/{handle}/ydna", headers=headers)
88+
assert rv.status_code == 200
89+
assert rv.json == {}
90+
91+
def test_with_ydna(self):
92+
headers = get_headers(self.client, "admin", "123")
93+
handle = make_handle()
94+
ydna_string = "M269+, CTS1078+, P312+, U106-, L21-, Z290-, Z2103+, FGC3845-"
95+
person = {
96+
"_class": "Person",
97+
"handle": handle,
98+
"primary_name": {
99+
"_class": "Name",
100+
"surname_list": [{"_class": "Surname", "surname": "Smith"}],
101+
"first_name": "Adam",
102+
},
103+
"gender": 1,
104+
"attribute_list": [
105+
{"_class": "Attribute", "type": "Y-DNA", "value": ydna_string}
106+
],
107+
}
108+
rv = self.client.post("/api/objects/", json=[person], headers=headers)
109+
assert rv.status_code == 201
110+
rv = self.client.get(f"/api/people/{handle}/ydna", headers=headers)
111+
assert rv.status_code == 200
112+
assert "clade_lineage" in rv.json
113+
assert all(
114+
"name" in item and "age_info" in item for item in rv.json["clade_lineage"]
115+
)
116+
assert "raw_data" not in rv.json
117+
rv = self.client.get(f"/api/people/{handle}/ydna?raw=1", headers=headers)
118+
assert "raw_data" in rv.json
119+
assert rv.json["raw_data"] == ydna_string

0 commit comments

Comments
 (0)