diff --git a/funannotate2/predict.py b/funannotate2/predict.py index e78a423..fd1ca64 100755 --- a/funannotate2/predict.py +++ b/funannotate2/predict.py @@ -34,8 +34,8 @@ ) from .log import finishLogging, startLogging, system_info from .utilities import ( + busco_lineage_from_taxonomy, checkfile, - choose_best_busco_species, create_directories, create_tmpdir, ensure_busco_lineage, @@ -903,10 +903,15 @@ def sort_gff_line(line): logger.warning( "Unable to access JGI taxonomy lookup, reverting to taxonomy from training data" ) - taxonomy = params["taxonomy"] - busco_tax = choose_best_busco_species( - {"superkingdom": taxonomy.get("superkingdom"), "kingdom": taxonomy.get("kingdom")} - ) + taxonomy = params.get("taxonomy") + busco_tax = busco_lineage_from_taxonomy(taxonomy, default=None) + if busco_tax is None: + busco_tax = "fungi" + logger.warning( + f"Could not resolve a BUSCO lineage for species '{args.species}' " + f"(taxonomy unavailable or unrecognized); defaulting to '{busco_tax}'. " + "BUSCO completeness scoring will be unreliable for a non-fungal genome." + ) busco_model_path = ensure_busco_lineage(busco_tax, logger) # now we can loop through the abinitio predictions and run busco for completion diff --git a/funannotate2/utilities.py b/funannotate2/utilities.py index 4014ab2..b4a9d54 100755 --- a/funannotate2/utilities.py +++ b/funannotate2/utilities.py @@ -497,6 +497,53 @@ def choose_best_busco_species(query_tax): return best_taxonomy(query_tax, busco_taxonomy, exact=True) +def busco_lineage_from_taxonomy(taxonomy, default="fungi"): + """ + Resolve a valid BUSCO lineage key from a taxonomy dict, deterministically. + + `predict` gets taxonomy from `lookup_taxonomy` (which returns False when the + JGI lookup fails) and falls back to the training-data taxonomy (which may + also be False for a novel organism). Passing such a value straight into + `choose_best_busco_species` either crashed (`.get()` on a bool) or, for a + non-empty but unmatched taxonomy, returned a random lineage via + `best_taxonomy`'s tie-break. This helper guards both cases and always returns + a lineage that exists in `busco_taxonomy`. + + Parameters: + - taxonomy (dict or None): a taxonomy dict, or a falsy value (False/None) when unavailable. + - default (str): lineage to fall back to; must be a valid busco_taxonomy key. + + Returns: + - str: a lineage key guaranteed to be present in busco_taxonomy. + """ + if not isinstance(taxonomy, dict) or not taxonomy: + return default + query = { + "superkingdom": taxonomy.get("superkingdom"), + "kingdom": taxonomy.get("kingdom"), + } + + # best_taxonomy() falls back to a random tie-break when nothing matches, and + # that random pick is always itself a valid busco_taxonomy key -- so a plain + # "result not in busco_taxonomy" check can never catch it. Only trust the + # matcher when the query actually overlaps the reference on superkingdom or + # kingdom; otherwise return the default deterministically. + def _overlaps(level): + value = query.get(level) + if not isinstance(value, str): + return False + value = value.lower() + return any( + isinstance(ref.get(level), str) and ref[level].lower() == value + for ref in busco_taxonomy.values() + ) + + if not (_overlaps("superkingdom") or _overlaps("kingdom")): + return default + lineage = choose_best_busco_species(query) + return lineage if lineage in busco_taxonomy else default + + def best_taxonomy(query, reference, exact=False): """ Find the best matching taxonomy in a reference dictionary based on a query taxonomy. diff --git a/tests/unit/test_utilities_taxonomy.py b/tests/unit/test_utilities_taxonomy.py index f8ccb42..d553550 100644 --- a/tests/unit/test_utilities_taxonomy.py +++ b/tests/unit/test_utilities_taxonomy.py @@ -5,7 +5,10 @@ from unittest.mock import patch import funannotate2.utilities -from funannotate2.utilities import choose_best_busco_species +from funannotate2.utilities import ( + busco_lineage_from_taxonomy, + choose_best_busco_species, +) from funannotate2.config import busco_taxonomy @@ -351,3 +354,116 @@ def test_returns_valid_busco_key_always(self): assert result in busco_taxonomy, ( f"Result '{result}' should be a valid key in busco_taxonomy" ) + + +class TestBuscoLineageFromTaxonomy: + """Tests for busco_lineage_from_taxonomy. + + Regression coverage for issue #93 (crash on missing taxonomy) plus the + latent random-lineage path: a non-empty taxonomy that matches nothing in + busco_taxonomy must resolve to the default deterministically, not to a + random tie-broken lineage. + """ + + def test_false_returns_default_without_crashing(self): + # Issue #93: predict() fed a `False` taxonomy straight into `.get()`, + # raising `AttributeError: 'bool' object has no attribute 'get'`. + assert busco_lineage_from_taxonomy(False) == "fungi" + + def test_none_returns_default(self): + assert busco_lineage_from_taxonomy(None) == "fungi" + + def test_empty_dict_returns_default(self): + assert busco_lineage_from_taxonomy({}) == "fungi" + + def test_real_match_returns_valid_lineage(self): + # Exact, deterministic match -- not merely "some valid key", which would + # also pass if _overlaps() regressed and everything silently defaulted. + result = busco_lineage_from_taxonomy( + {"superkingdom": "Eukaryota", "kingdom": "Fungi"} + ) + assert result == "fungi" + + def test_partial_but_matching_dict_returns_valid_lineage(self): + # superkingdom-only match resolves to the broad "eukaryota" lineage. + result = busco_lineage_from_taxonomy({"superkingdom": "Eukaryota"}) + assert result == "eukaryota" + + def test_non_matching_dict_is_deterministic_default(self): + # A non-empty dict whose values match nothing would otherwise fall + # through best_taxonomy to random.choice(); the helper must not. + results = { + busco_lineage_from_taxonomy( + {"superkingdom": "Bacteria", "kingdom": "Nonexistent"} + ) + for _ in range(50) + } + assert results == {"fungi"} + + def test_all_none_is_deterministic_default(self): + results = { + busco_lineage_from_taxonomy({"superkingdom": None, "kingdom": None}) + for _ in range(50) + } + assert results == {"fungi"} + + def test_result_is_always_a_valid_lineage(self): + inputs = [ + False, + None, + {}, + {"superkingdom": "Eukaryota", "kingdom": "Fungi"}, + {"superkingdom": "Eukaryota"}, + {"superkingdom": "Bacteria", "kingdom": "Nonexistent"}, + ] + for tax in inputs: + assert busco_lineage_from_taxonomy(tax) in busco_taxonomy + + def test_default_none_signals_unresolved(self): + # predict() passes default=None to detect when it must warn and fall back. + assert busco_lineage_from_taxonomy(False, default=None) is None + assert ( + busco_lineage_from_taxonomy( + {"superkingdom": "Bacteria", "kingdom": "Nonexistent"}, default=None + ) + is None + ) + # a genuine match still returns a real lineage, not the sentinel + assert ( + busco_lineage_from_taxonomy( + {"superkingdom": "Eukaryota", "kingdom": "Fungi"}, default=None + ) + in busco_taxonomy + ) + + def test_default_is_overridable(self): + assert ( + busco_lineage_from_taxonomy(False, default="eukaryota") == "eukaryota" + ) + + def test_every_kingdom_resolves_deterministically(self): + # The determinism guarantee currently rests on the busco_taxonomy data + # shape, not on structure. Sweep every real kingdom with no superkingdom + # (the shape most likely to reach best_taxonomy's random tie-break) and + # assert a single stable result across many runs, so a future reference + # edit that reintroduces a random pick fails here instead of shipping. + kingdoms = { + v.get("kingdom") for v in busco_taxonomy.values() if v.get("kingdom") + } + for kingdom in kingdoms: + results = { + busco_lineage_from_taxonomy({"superkingdom": None, "kingdom": kingdom}) + for _ in range(25) + } + assert len(results) == 1, f"non-deterministic lineage for {kingdom}: {results}" + assert results.pop() in busco_taxonomy + + def test_non_string_values_do_not_crash(self): + # Guards the isinstance(value, str) checks in _overlaps: odd taxonomy + # values must degrade to a valid default, never raise. + for tax in [ + {"superkingdom": 123, "kingdom": ["Fungi"]}, + {"superkingdom": {"x": 1}, "kingdom": None}, + {"superkingdom": "", "kingdom": ""}, + ]: + assert busco_lineage_from_taxonomy(tax) in busco_taxonomy