Skip to content
Merged
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
13 changes: 9 additions & 4 deletions funannotate2/annotate.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
load_json,
lookup_taxonomy,
naming_slug,
resolve_annotation_key,
validate_busco_lineage,
)

Expand Down Expand Up @@ -501,10 +502,14 @@ def annotate(args):
n["gene_synonym"] += fa["name"][1:]
if "note" in fa:
n["note"][i] = fa["note"]
if "ec_number" in fa:
n["ec_number"][i] = fa["ec_number"]
if "go_terms" in fa:
n["go_terms"][i] = fa["go_terms"]
# accept the case/plural key variants eggNOG (EC_number) and
# InterProScan (go_term) emit, which exact-key checks dropped (#96)
ec_key = resolve_annotation_key(fa, "ec_number")
if ec_key is not None:
n["ec_number"][i] = fa[ec_key]
go_key = resolve_annotation_key(fa, "go_terms")
if go_key is not None:
n["go_terms"][i] = fa[go_key]
Annotation[k] = n

logger.info("Converting to GenBank format")
Expand Down
36 changes: 36 additions & 0 deletions funannotate2/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,42 @@ def similarity_score(query, ref):
return sorted(best_matches, key=str.lower)[0] if best_matches else None


def resolve_annotation_key(fa, canonical_key):
"""
Return the key present in `fa` that maps to `canonical_key`, tolerant of the
case/plural variants real annotation sources emit, or None if absent.

annotate() merges functional annotations with exact-key checks, but eggNOG /
funannotate2-addons write the EC number under `EC_number` and InterProScan
writes GO terms under `go_term` (singular), while funannotate2's annotation
object uses `ec_number` / `go_terms`. Exact-key matching therefore silently
dropped those annotations (#96). A variant that actually carries data is
preferred, so a present-but-empty canonical key never shadows a populated
variant; the canonical key is first in each tuple, so it still wins when both
hold data (leaving canonical-key inputs unchanged).

Parameters:
- fa (dict): a functional-annotation dict for one gene.
- canonical_key (str): the funannotate2 key to resolve (e.g. "ec_number").

Returns:
- str or None: the matching key present in `fa`, or None if none is present.
"""
variants = {
"ec_number": ("ec_number", "EC_number"),
"go_terms": ("go_terms", "go_term"),
}.get(canonical_key, (canonical_key,))
present = [key for key in variants if key in fa]
if not present:
return None
# prefer a variant that actually carries data, so a present-but-empty canonical
# key doesn't shadow a populated variant (re-dropping #96 data)
for key in present:
if fa[key]:
return key
return present[0]


def which_path(file_name):
"""
Find the full path of a file by searching through the directories in the system's PATH environment variable.
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/test_utilities_annotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""
Unit tests for functional-annotation key resolution in utilities.py.
"""

from funannotate2.utilities import resolve_annotation_key


class TestResolveAnnotationKey:
"""Regression coverage for issue #96.

annotate() merged functional annotations with exact-key checks
(`if "ec_number" in fa` / `if "go_terms" in fa`), but eggNOG/addons emit the
EC number under `EC_number` and InterProScan emits GO terms under `go_term`
(singular), so those annotations were silently dropped. resolve_annotation_key
tolerates the known case/plural variants.
"""

def test_canonical_ec_number(self):
assert (
resolve_annotation_key({"ec_number": [["1.1.1.1"]]}, "ec_number")
== "ec_number"
)

def test_capitalized_ec_number_variant(self):
# eggNOG / funannotate2-addons emit "EC_number"; dropped before the fix.
assert (
resolve_annotation_key({"EC_number": [["1.1.1.1"]]}, "ec_number")
== "EC_number"
)

def test_canonical_go_terms(self):
assert (
resolve_annotation_key({"go_terms": [["GO:0008150"]]}, "go_terms")
== "go_terms"
)

def test_singular_go_term_variant(self):
# InterProScan emits "go_term" (singular); dropped before the fix.
assert (
resolve_annotation_key({"go_term": [["GO:0008150"]]}, "go_terms")
== "go_term"
)

def test_missing_key_returns_none(self):
assert resolve_annotation_key({"product": ["x"]}, "ec_number") is None
assert resolve_annotation_key({}, "go_terms") is None

def test_canonical_preferred_when_both_present(self):
# Canonical wins so canonical-key inputs behave exactly as before #96.
assert (
resolve_annotation_key({"ec_number": 1, "EC_number": 2}, "ec_number")
== "ec_number"
)

def test_unknown_canonical_key_matches_itself_only(self):
# A key with no known variants resolves only to itself.
assert resolve_annotation_key({"note": ["x"]}, "note") == "note"
assert resolve_annotation_key({"other": 1}, "note") is None

def test_populated_variant_wins_over_empty_canonical(self):
# #96 intent is to never re-drop data: an empty canonical key must not
# shadow a populated variant when both are present.
assert (
resolve_annotation_key(
{"ec_number": [], "EC_number": [["1.1.1.1"]]}, "ec_number"
)
== "EC_number"
)

def test_present_but_none_value_preserved(self):
# A present key with a falsy value still resolves (the old code used
# key-presence, not truthiness); the annotate() call site then assigns it,
# preserving the pre-fix behavior for that edge.
assert resolve_annotation_key({"ec_number": None}, "ec_number") == "ec_number"
Loading