From 7cdb9072609fd38f33e8b3a144354626eea2325e Mon Sep 17 00:00:00 2001 From: Vin Vomero <36705133+vinvomero@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:37:45 -0400 Subject: [PATCH] fix: accept case/plural EC/GO annotation key variants in annotate (addresses #96) annotate() merged functional annotations with exact-key checks (`if "ec_number" in fa` / `if "go_terms" in fa`), but eggNOG / funannotate2-addons emit the EC number under `EC_number` and InterProScan emits GO terms under `go_term` (singular), so those annotations were silently dropped. In the reporter's dataset, a local patch for these keys recovered 5,296 GO terms. Add utilities.resolve_annotation_key(), which resolves the canonical funannotate2 key (ec_number/go_terms) tolerant of the known case/plural variants. A key that actually carries data is preferred, so an empty canonical key never hides a populated variant; when both carry data the canonical key wins, so canonical-key inputs are unchanged. Route the EC/GO merge in annotate() through it. Addresses the EC/GO portion of #96. The InterPro/SignalP summary counts the reporter also notes are computed in the gfftk dependency (gfftk/stats.py) and are out of scope here. New tests: tests/unit/test_utilities_annotation.py. Co-Authored-By: Claude Opus 5.5 --- funannotate2/annotate.py | 13 +++-- funannotate2/utilities.py | 36 ++++++++++++ tests/unit/test_utilities_annotation.py | 74 +++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_utilities_annotation.py diff --git a/funannotate2/annotate.py b/funannotate2/annotate.py index 47074a4..0feb1fd 100755 --- a/funannotate2/annotate.py +++ b/funannotate2/annotate.py @@ -46,6 +46,7 @@ load_json, lookup_taxonomy, naming_slug, + resolve_annotation_key, validate_busco_lineage, ) @@ -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") diff --git a/funannotate2/utilities.py b/funannotate2/utilities.py index 7e1c14e..50267e5 100755 --- a/funannotate2/utilities.py +++ b/funannotate2/utilities.py @@ -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. diff --git a/tests/unit/test_utilities_annotation.py b/tests/unit/test_utilities_annotation.py new file mode 100644 index 0000000..9e6a88f --- /dev/null +++ b/tests/unit/test_utilities_annotation.py @@ -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"