Repository: cogent3/cogent3 (python)

Issue:
ENH: extend support for bootstrap values from newick strings
`cogent3` stores support values (e.g. from a bootstrap) as `<phylonode>.params["support"]` entry. This information is also stored in some versions of newick strings. For example, IQTree uses `((a,b)##:0.1,..` and `((a,b)node_name/##:0.1,..` where `##` is an integer to record support measures. The current cogent3 parser treats both those values as name. We need to add code that correctly transforms these names into the name and support values.

To address this issue, write a function `split_name_and_support(field: str | None) -> tuple[str | None, float | None)` in `cogent3.core.tree`.

It should cope with them being either a name and no support, support and no name, or both. Specific cases are:

- field is `None` or an empty string, returns `None, None`
- field is `"edge.98/24"` returns `"edge.98", 24.0`
- field is `"edge.98"` returns `"edge.98", None`
- field is `"24"` returns `None, 24.0`


Pull request description:
ENH: add function to split the internal name field in newick into name and support value, fixes #2045
#2045

## Summary by Sourcery

Add a new function to split internal node name fields in Newick format into separate name and support values, and implement corresponding tests to ensure correct functionality.

New Features:
- Introduce a function `split_name_and_support` to handle Newick format internal node name fields that contain both a name and a support value.

Tests:
- Add parameterized tests for the `split_name_and_support` function to verify its behavior with various input cases, including valid and invalid formats.

Patch:
diff --git a/src/cogent3/core/tree.py b/src/cogent3/core/tree.py
index 3df647b30..d12319495 100644
--- a/src/cogent3/core/tree.py
+++ b/src/cogent3/core/tree.py
@@ -27,6 +27,7 @@ Definition of relevant terms or abbreviations:
 
 from __future__ import annotations
 
+import contextlib
 import json
 import numbers
 import re
@@ -1696,6 +1697,7 @@ class PhyloNode(TreeNode):
             params["length"] = length
         kwargs["params"] = params
         super(PhyloNode, self).__init__(*args, **kwargs)
+        # split name into name/support here
 
     def _set_length(self, value):
         if not hasattr(self, "params"):
@@ -2179,6 +2181,39 @@ class PhyloNode(TreeNode):
                 n.MaxDistTips = [tip_a, tip_b]
 
 
+def split_name_and_support(name_field: str | None) -> tuple[str | None, float | None]:
+    """Handle cases in the Newick format where an internal node name field
+    contains a name or/and support value, like 'edge.98/100'.
+    """
+    # handle the case where the name field is None or empty string
+    if not name_field:
+        return None, None
+
+    # if name_field is "24", treat it as support, returns (None, 24.0)
+    with contextlib.suppress(ValueError):
+        return None, float(name_field)
+
+    # otherwise, split the name field into name and support
+    name, *support = name_field.split("/")
+
+    if len(support) == 1:
+        try:
+            support_value = float(support[0])
+        except ValueError as e:
+            raise ValueError(
+                f"Support value at node: {name!r} should be int or float not {support[0]!r}."
+            ) from e
+    # handle case where mutiple '/' in the name field
+    elif len(support) > 1:
+        raise ValueError(
+            f"Support value at node: {name!r} should be int or float not {'/'.join(support)!r}."
+        )
+    else:
+        support_value = None
+
+    return name, support_value
+
+
 class TreeBuilder(object):
     # Some tree code which isn't needed once the tree is finished.
     # Mostly exists to give edges unique names


Test patch:
diff --git a/tests/test_core/test_tree.py b/tests/test_core/test_tree.py
index c05b8d42a..cca1f4342 100644
--- a/tests/test_core/test_tree.py
+++ b/tests/test_core/test_tree.py
@@ -14,7 +14,8 @@ from numpy.testing import assert_allclose, assert_equal
 
 from cogent3 import load_tree, make_tree, open_
 from cogent3._version import __version__
-from cogent3.core.tree import PhyloNode, TreeError, TreeNode
+from cogent3.core.tree import (PhyloNode, TreeError, TreeNode,
+                               split_name_and_support)
 from cogent3.maths.stats.test import correlation
 from cogent3.parse.tree import DndParser
 from cogent3.util.misc import get_object_provenance
@@ -2332,3 +2333,23 @@ def test_load_tree_bad_encoding():
             f.write(newick.encode("ascii"))
 
         assert load_tree(tree_path).get_newick() == newick
+
+
+@pytest.mark.parametrize(
+    "name,expected",
+    (
+        (None, (None, None)),
+        ("", (None, None)),
+        ("edge.98/24", ("edge.98", 24.0)),
+        ("edge.98", ("edge.98", None)),
+        ("24", (None, 24.0)),
+    ),
+)
+def test_split_name_and_support(name, expected):
+    assert split_name_and_support(name) == expected
+
+
+@pytest.mark.parametrize("invalid", ("edge.98/invalid", "edge.98/23/invalid"))
+def test_split_name_and_support_invalid_support(invalid):
+    with pytest.raises(ValueError):
+        split_name_and_support(invalid)