Skip to content

Commit

Permalink
Add DNA match parser resource (#604)
Browse files Browse the repository at this point in the history
* Add DNA match parser resource

* Fix typing and linting issues

* Update apispec, version -> 2.8.0

* Add unit test
  • Loading branch information
DavidMStraub authored Jan 16, 2025
1 parent 68b7e34 commit 1b23880
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 44 deletions.
15 changes: 9 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
repos:
- repo: https://github.com/pre-commit/mirrors-isort
rev: v5.10.1
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: ["--profile", "black"]
- repo: https://github.com/psf/black
rev: 24.8.0
rev: 24.10.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2
rev: v1.14.1
hooks:
- id: mypy
args: [--ignore-missing-imports]
args: [--ignore-missing-imports, --no-strict-optional]
additional_dependencies:
- types-setuptools
- repo: https://github.com/PyCQA/pylint
rev: v3.2.7
rev: v3.3.3
hooks:
- id: pylint
stages: [commit]
args:
- --score=n
- --disable=import-error,arguments-differ,too-many-locals
- repo: https://github.com/PyCQA/pydocstyle
rev: 6.3.0
hooks:
Expand Down
5 changes: 4 additions & 1 deletion gramps_webapi/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@
#

# make sure to match this version with the one in apispec.yaml
__version__ = "2.7.0"

"""Version information."""

__version__ = "2.8.0"
4 changes: 3 additions & 1 deletion gramps_webapi/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from .resources.chat import ChatResource
from .resources.citations import CitationResource, CitationsResource
from .resources.config import ConfigResource, ConfigsResource
from .resources.dna import PersonDnaMatchesResource
from .resources.dna import PersonDnaMatchesResource, DnaMatchParserResource
from .resources.events import EventResource, EventSpanResource, EventsResource
from .resources.export_media import MediaArchiveFileResource, MediaArchiveResource
from .resources.exporters import (
Expand Down Expand Up @@ -235,6 +235,8 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
# Translations
register_endpt(TranslationResource, "/translations/<string:language>", "translation")
register_endpt(TranslationsResource, "/translations/", "translations")
# Parsers
register_endpt(DnaMatchParserResource, "/parsers/dna-match", "dna-match-parser")
# Relations
register_endpt(
RelationResource,
Expand Down
2 changes: 1 addition & 1 deletion gramps_webapi/api/resources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def _parse_object(self) -> GrampsObject:
try:
obj_dict = fix_object_dict(obj_dict)
except ValueError as exc:
abort_with_message(400, "Error while processing object")
abort_with_message(400, f"Error while processing object: {exc}")
if not validate_object_dict(obj_dict):
abort_with_message(400, "Schema validation failed")
return from_json(json.dumps(obj_dict))
Expand Down
45 changes: 34 additions & 11 deletions gramps_webapi/api/resources/dna.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,32 @@

"""DNA resources."""

from typing import Any, Dict, List, Optional, Union
from __future__ import annotations

from typing import Any, Union

from flask import abort
from gramps.gen.const import GRAMPS_LOCALE as glocale
from gramps.gen.db.base import DbReadBase
from gramps.gen.errors import HandleError
from gramps.gen.lib import Person, PersonRef
from gramps.gen.lib import Note, Person, PersonRef
from gramps.gen.relationship import get_relationship_calculator
from gramps.gen.utils.grampslocale import GrampsLocale
from webargs import fields, validate

from gramps_webapi.api.people_families_cache import CachePeopleFamiliesProxy
from gramps_webapi.types import ResponseReturnValue

from ...types import Handle
from ..util import get_db_handle, get_locale_for_language, use_args
from .util import get_person_profile_for_handle
from . import ProtectedResource
from .util import get_person_profile_for_handle

SIDE_UNKNOWN = "U"
SIDE_MATERNAL = "M"
SIDE_PATERNAL = "P"

Segment = Dict[str, Union[float, int, str]]
Segment = dict[str, Union[float, int, str]]


class PersonDnaMatchesResource(ProtectedResource):
Expand All @@ -57,7 +60,7 @@ class PersonDnaMatchesResource(ProtectedResource):
},
location="query",
)
def get(self, args: Dict, handle: str):
def get(self, args: dict, handle: str):
"""Get the DNA match data."""
db_handle = CachePeopleFamiliesProxy(get_db_handle())

Expand All @@ -84,12 +87,24 @@ def get(self, args: Dict, handle: str):
return matches


class DnaMatchParserResource(ProtectedResource):
"""DNA match parser resource."""

@use_args(
{"string": fields.Str(required=True)},
location="json",
)
def post(self, args: dict) -> ResponseReturnValue:
"""Parse DNA match string."""
return parse_raw_dna_match_string(args["string"])


def get_match_data(
db_handle: DbReadBase,
person: Person,
association: PersonRef,
locale: GrampsLocale = glocale,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""Get the DNA match data in the appropriate format."""
relationship = get_relationship_calculator(reinit=True, clocale=locale)
associate = db_handle.get_person_from_handle(association.ref)
Expand Down Expand Up @@ -154,19 +169,27 @@ def get_match_data(


def get_segments_from_note(
db_handle: DbReadBase, handle: Handle, side: Optional[str] = None
) -> List[Segment]:
db_handle: DbReadBase, handle: Handle, side: str | None = None
) -> list[Segment]:
"""Get the segements from a note handle."""
note = db_handle.get_note_from_handle(handle)
note: Note = db_handle.get_note_from_handle(handle)
raw_string: str = note.get()
return parse_raw_dna_match_string(raw_string, side=side)


def parse_raw_dna_match_string(
raw_string: str, side: str | None = None
) -> list[Segment]:
"""Parse a raw DNA match string and return a list of segments."""
segments = []
for line in note.get().split("\n"):
for line in raw_string.split("\n"):
data = parse_line(line, side=side)
if data:
segments.append(data)
return segments


def parse_line(line: str, side: Optional[str] = None) -> Optional[Segment]:
def parse_line(line: str, side: str | None = None) -> Segment | None:
"""Parse a line from the CSV/TSV data and return a dictionary."""
if "\t" in line:
# Tabs are the field separators. Now determine THOUSEP and RADIXCHAR.
Expand Down
33 changes: 32 additions & 1 deletion gramps_webapi/data/apispec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
swagger: "2.0"
info:
title: "Gramps Web API"
version: "2.7.0"
version: "2.8.0"
description: >
The Gramps Web API is a REST API that provides access to family tree databases generated and maintained with Gramps, a popular Open Source genealogical research software package.
Expand Down Expand Up @@ -7128,6 +7128,37 @@ paths:
description: "Unauthorized: Missing authorization header."


##############################################################################
# Endpoint - Parsers
##############################################################################

/parsers/dna-match:
post:
tags:
- parsers
summary: "Parse a DNA match file."
operationId: parseDnaMatch
security:
- Bearer: []
parameters:
- name: string
in: json
required: true
type: string
description: "The raw DNA match data to parse."
responses:
200:
description: "OK: sucessfully parser."
schema:
type: array
items:
$ref: "#/definitions/DnaMatch"
401:
description: "Unauthorized: Missing authorization header."
422:
description: "Unprocessable Entity: Invalid or bad parameter provided."


##############################################################################
# Endpoint - Trees
##############################################################################
Expand Down
44 changes: 21 additions & 23 deletions tests/test_endpoints/test_dna.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#
# Gramps Web API - A RESTful API for the Gramps genealogy program
#
# Copyright (C) 2023 David Straub
# Copyright (C) 2023-25 David Straub
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
Expand Down Expand Up @@ -39,14 +39,13 @@
)
from gramps_webapi.const import ENV_CONFIG_FILE, TEST_AUTH_CONFIG


match1 = """chromosome,start,end,cMs,SNP
MATCH1 = """chromosome,start,end,cMs,SNP
1,56950055,64247327,10.9,1404
5,850055,950055,12,1700
"""
match2 = """chromosome start end cMs SNP Side
MATCH2 = """chromosome start end cMs SNP Side
2 56950055 64247327 10.9 1404 M"""
match3 = """chromosome,start,end,cMs
MATCH3 = """chromosome,start,end,cMs
X,56950055,64247327,10.9"""


Expand All @@ -62,13 +61,6 @@ def make_handle() -> str:
return str(uuid.uuid4())


def get_headers(client, user: str, password: str) -> Dict[str, str]:
"""Get the auth headers for a specific user."""
rv = client.post("/api/token/", json={"username": user, "password": password})
access_token = rv.json["access_token"]
return {"Authorization": "Bearer {}".format(access_token)}


class TestDnaMatches(unittest.TestCase):
@classmethod
def setUpClass(cls):
Expand Down Expand Up @@ -127,7 +119,7 @@ def test_no_assoc(self):
assert rv.json == []

def test_one(self):
"""Test without association."""
"""Full test."""
headers = get_headers(self.client, "admin", "123")
handle_p1 = make_handle()
handle_p2 = make_handle()
Expand Down Expand Up @@ -255,17 +247,17 @@ def test_one(self):
{
"_class": "Note",
"handle": handle_n1,
"text": {"_class": "StyledText", "string": match1},
"text": {"_class": "StyledText", "string": MATCH1},
},
{
"_class": "Note",
"handle": handle_n2,
"text": {"_class": "StyledText", "string": match2},
"text": {"_class": "StyledText", "string": MATCH2},
},
{
"_class": "Note",
"handle": handle_n3,
"text": {"_class": "StyledText", "string": match3},
"text": {"_class": "StyledText", "string": MATCH3},
},
]
rv = self.client.post("/api/objects/", json=objects, headers=headers)
Expand All @@ -280,13 +272,6 @@ def test_one(self):
assert data["ancestor_handles"] == [handle_grandf]
assert data["relation"] == "le premier cousin"
assert len(data["segments"]) == 4
# 1,56950055,64247327,10.9,1404
# 5,850055,950055,12,1700
# """
# match2 = """chromosome start end cMs SNP Side
# 2 56950055 64247327 10.9 1404 M"""
# match3 = """chromosome,start,end,cMs
# X,56950055,64247327,10.9"""
assert data["segments"][0] == {
"chromosome": "1",
"start": 56950055,
Expand Down Expand Up @@ -323,3 +308,16 @@ def test_one(self):
"SNPs": 0,
"comment": "",
}
# empty string
rv = self.client.post(
f"/api/parsers/dna-match", headers=headers, json={"string": ""}
)
assert rv.status_code == 200
assert rv.json == []
rv = self.client.post(
f"/api/parsers/dna-match", headers=headers, json={"string": MATCH1}
)
assert rv.status_code == 200
data = rv.json
assert data
assert len(data) == 2

0 comments on commit 1b23880

Please sign in to comment.