Repository: cogent3/cogent3
Pull request #2371 (2025-06-23 00:15:59)

API: added with_root_name=True argument to get_newick()
[NEW] if with_node_names and with_root_name, then the
    name of the root node is appended to the newick string.
    The default is False.

## Summary by Sourcery

Add a with_root_name argument to get_newick to optionally include the root node's name in Newick-formatted strings, updating internal formatting logic and tests accordingly

New Features:
- Allow optionally including the root node's name in Newick output via the new with_root_name parameter

Enhancements:
- Extend get_newick and _format_node_name to accept and propagate the with_root_name flag to control root name inclusion

Tests:
- Update existing Newick output tests to cover cases when with_root_name is True


Patch:
diff --git a/src/cogent3/cluster/UPGMA.py b/src/cogent3/cluster/UPGMA.py
index 23075ef4a..cc69f6d49 100644
--- a/src/cogent3/cluster/UPGMA.py
+++ b/src/cogent3/cluster/UPGMA.py
@@ -98,10 +98,10 @@ def condense_node_order(matrix, smallest_index, node_order):
     d = distance / 2.0
     for n in nodes:
         if n.children:
-            n.length = d - n.children[0].TipLength
+            n.length = d - n.children[0].params["TipLength"]
         else:
             n.length = d
-        n.TipLength = d
+        n.params["TipLength"] = d
     # combine the two nodes into a new PhyloNode object
     new_node = PhyloNode()
     new_node.children.append(node1)
diff --git a/src/cogent3/core/tree.py b/src/cogent3/core/tree.py
index 140dd8e2a..c9f9b15ea 100644
--- a/src/cogent3/core/tree.py
+++ b/src/cogent3/core/tree.py
@@ -81,10 +81,16 @@ def _copy_node(n):
 
 
 def _format_node_name(
-    node, with_node_names: bool, escape_name: bool, with_distances: bool
+    node,
+    with_node_names: bool,
+    escape_name: bool,
+    with_distances: bool,
+    with_root_name: bool = False,
 ) -> str:
     """Helper function to format node name according to parameters"""
-    if not node.is_tip() and not with_node_names:
+    if (node.is_root() and not with_root_name) or (
+        not node.is_tip() and not with_node_names
+    ):
         node_name = ""
     else:
         node_name = node.name or ""
@@ -827,6 +833,7 @@ class TreeNode:
             with_node_names=True,
             semicolon=False,
             escape_name=False,
+            with_root_name=True,
         )
         attr = {}
         for edge in self.get_edge_vector(include_root=True):
@@ -848,6 +855,7 @@ class TreeNode:
         semicolon: bool = True,
         escape_name: bool = True,
         with_node_names: bool = False,
+        with_root_name: bool = False,
     ) -> str:
         """Return the newick string of node and its descendents
 
@@ -862,6 +870,9 @@ class TreeNode:
             nodes name, wrap the name in single quotes
         with_node_names
             includes internal node names
+        with_root_name
+            if True and with_node_names, the root node will have
+            its name included
         """
         # Stack contains tuples of (tree node, visit flag)
         stack = [(self, False)]
@@ -882,6 +893,7 @@ class TreeNode:
                     with_node_names=with_node_names,
                     escape_name=escape_name,
                     with_distances=with_distances,
+                    with_root_name=with_root_name,
                 )
 
                 # for tips with parent, the typical case


Test patch:
diff --git a/tests/test_cluster/test_UPGMA.py b/tests/test_cluster/test_UPGMA.py
index 089c461e2..10cedb9fa 100644
--- a/tests/test_cluster/test_UPGMA.py
+++ b/tests/test_cluster/test_UPGMA.py
@@ -109,9 +109,6 @@ class UPGMATests(TestCase):
         node_order = condense_node_order(matrix, index, node_order)
         assert node_order[1] is None
         assert str(node_order[0]) == "(a:0.5,b:0.5);"
-        assert str(node_order[2]) == "c;"
-        assert str(node_order[3]) == "d;"
-        assert str(node_order[4]) == "e;"
 
     def test_upgma_cluster(self):
         """UPGMA_cluster clusters nodes based on info in a matrix with UPGMA"""
diff --git a/tests/test_core/test_tree.py b/tests/test_core/test_tree.py
index d2c3dd557..dc235a3d8 100644
--- a/tests/test_core/test_tree.py
+++ b/tests/test_core/test_tree.py
@@ -133,22 +133,30 @@ def test_str(empty_node, one_child, big_parent):
     """TreeNode str should give Newick-style representation"""
     # note: name suppressed if None
     assert str(empty_node) == ";"
-    assert one_child.get_newick(with_node_names=True) == "(b)a;"
-    assert big_parent.get_newick(with_node_names=True) == "(0,1,2,3,4,5,6,7,8,9)x;"
+    assert one_child.get_newick(with_node_names=True, with_root_name=True) == "(b)a;"
+    assert (
+        big_parent.get_newick(with_node_names=True, with_root_name=True)
+        == "(0,1,2,3,4,5,6,7,8,9)x;"
+    )
     big_parent[-1].extend("abc")
     assert (
-        big_parent.get_newick(with_node_names=True) == "(0,1,2,3,4,5,6,7,8,(a,b,c)9)x;"
+        big_parent.get_newick(with_node_names=True, with_root_name=True)
+        == "(0,1,2,3,4,5,6,7,8,(a,b,c)9)x;"
     )
 
 
 def test_get_newick(empty_node, one_child, big_parent):
     """Should return Newick-style representation"""
     assert empty_node.get_newick() == ";"
-    assert one_child.get_newick(with_node_names=True) == "(b)a;"
-    assert big_parent.get_newick(with_node_names=True) == "(0,1,2,3,4,5,6,7,8,9)x;"
+    assert one_child.get_newick(with_node_names=True, with_root_name=True) == "(b)a;"
+    assert (
+        big_parent.get_newick(with_node_names=True, with_root_name=True)
+        == "(0,1,2,3,4,5,6,7,8,9)x;"
+    )
     big_parent[-1].extend("abc")
     assert (
-        big_parent.get_newick(with_node_names=True) == "(0,1,2,3,4,5,6,7,8,(a,b,c)9)x;"
+        big_parent.get_newick(with_node_names=True, with_root_name=True)
+        == "(0,1,2,3,4,5,6,7,8,(a,b,c)9)x;"
     )
 
 
@@ -193,7 +201,10 @@ def test_write_to_json(DATA_DIR, tmp_path):
     with open_(json_path) as fn:
         got = json.loads(fn.read())
         assert got["type"] == get_object_provenance(PhyloNode)
-        assert tree.get_newick(semicolon=False, with_node_names=True) == got["newick"]
+        assert (
+            tree.get_newick(semicolon=False, with_node_names=True, with_root_name=True)
+            == got["newick"]
+        )
         assert set(tree.get_node_names()) == got["edge_attributes"].keys()
 
 
@@ -225,20 +236,32 @@ def test_multifurcating():
     # can't break up easily... sorry 80char
     exp_str = "((a:1.0,(b:2.0,c:3.0):0.0)d:4.0,((e:5.0,(f:6.0,g:7.0):0.0)h:8.0,(i:9.0,(j:10.0,k:11.0):0.0)l:12.0):0.0)m:14.0;"
     obs = t.multifurcating(2)
-    assert obs.get_newick(with_distances=True, with_node_names=True) == exp_str
+    assert (
+        obs.get_newick(with_distances=True, with_node_names=True, with_root_name=True)
+        == exp_str
+    )
     assert t.get_newick(with_distances=True) != obs.get_newick(with_distances=True)
 
     obs = t.multifurcating(2, 0.5)
     exp_str = "((a:1.0,(b:2.0,c:3.0):0.5)d:4.0,((e:5.0,(f:6.0,g:7.0):0.5)h:8.0,(i:9.0,(j:10.0,k:11.0):0.5)l:12.0):0.5)m:14.0;"
-    assert obs.get_newick(with_distances=True, with_node_names=True) == exp_str
+    assert (
+        obs.get_newick(with_distances=True, with_node_names=True, with_root_name=True)
+        == exp_str
+    )
 
     t_str = "((a,b,c)d,(e,f,g)h,(i,j,k)l)m;"
     exp_str = "((a,(b,c))d,((e,(f,g))h,(i,(j,k))l))m;"
     t = DndParser(t_str, constructor=TreeNode)
     obs = t.multifurcating(2)
-    assert obs.get_newick(with_distances=True, with_node_names=True) == exp_str
+    assert (
+        obs.get_newick(with_distances=True, with_node_names=True, with_root_name=True)
+        == exp_str
+    )
     obs = t.multifurcating(2, eps=10)  # no effect on TreeNode type
-    assert obs.get_newick(with_distances=True, with_node_names=True) == exp_str
+    assert (
+        obs.get_newick(with_distances=True, with_node_names=True, with_root_name=True)
+        == exp_str
+    )
 
     with pytest.raises(TreeError):
         # TreeNode does not support multifurcating with n=1
@@ -785,7 +808,10 @@ def test_newick_with_labelled_nodes():
             continue
         tree = make_tree(treestring=treestring)
         nwk = tree.get_newick(
-            with_node_names=True, with_distances=True, semicolon=False
+            with_node_names=True,
+            with_distances=True,
+            semicolon=False,
+            with_root_name=True,
         )
         assert nwk == expect[i]
 
@@ -1834,20 +1860,35 @@ def test_get_newick_2():
     orig = "((A:1.0,B:2.0)ab:3.0,((C:4.0,D:5.0)cd:6.0,E:7.0)cde:8.0)all;"
     unlen = "((A,B)ab,((C,D)cd,E)cde)all;"
     tree = _maketree(orig)
-    assert tree.get_newick(with_distances=True, with_node_names=True) == orig
-    assert tree.get_newick(with_node_names=True) == unlen
+    assert (
+        tree.get_newick(with_distances=True, with_node_names=True, with_root_name=True)
+        == orig
+    )
+    assert tree.get_newick(with_node_names=True, with_root_name=True) == unlen
 
     tree.name = "a'l"
     ugly_name = "((A,B)ab,((C,D)cd,E)cde)a'l;"
     ugly_name_esc = "((A,B)ab,((C,D)cd,E)cde)'a''l';"
-    assert tree.get_newick(escape_name=True, with_node_names=True) == ugly_name_esc
-    assert tree.get_newick(escape_name=False, with_node_names=True) == ugly_name
+    assert (
+        tree.get_newick(escape_name=True, with_node_names=True, with_root_name=True)
+        == ugly_name_esc
+    )
+    assert (
+        tree.get_newick(escape_name=False, with_node_names=True, with_root_name=True)
+        == ugly_name
+    )
 
     tree.name = "'a l'"
     quoted_name = "((A,B)ab,((C,D)cd,E)cde)'a l';"
     quoted_name_esc = "((A,B)ab,((C,D)cd,E)cde)'a l';"
-    assert tree.get_newick(escape_name=True, with_node_names=True) == quoted_name_esc
-    assert tree.get_newick(escape_name=False, with_node_names=True) == quoted_name
+    assert (
+        tree.get_newick(escape_name=True, with_node_names=True, with_root_name=True)
+        == quoted_name_esc
+    )
+    assert (
+        tree.get_newick(escape_name=False, with_node_names=True, with_root_name=True)
+        == quoted_name
+    )
 
 
 def test_XML():
@@ -2365,7 +2406,9 @@ def test_parser():
     nasty = "( (A :1.0,'B (b)': 2) [com\nment]pair:3,'longer name''s':4)dash_ed;"
     nice = "((A:1.0,'B (b)':2.0)pair:3.0,'longer name''s':4.0)dash_ed;"
     tree = make_tree(treestring=nasty, underscore_unmunge=True)
-    tidied = tree.get_newick(with_distances=True, with_node_names=True)
+    tidied = tree.get_newick(
+        with_distances=True, with_node_names=True, with_root_name=True
+    )
     assert tidied == nice
     assert tree.get_node_matching_name("pair").params["other"] == ["com\nment"]
 
diff --git a/tests/test_parse/test_tree.py b/tests/test_parse/test_tree.py
index d49ef65e8..8196f88d0 100644
--- a/tests/test_parse/test_tree.py
+++ b/tests/test_parse/test_tree.py
@@ -254,24 +254,33 @@ class PhyloNodeTests(TestCase):
         p = PhyloNode()
         assert p.get_newick(with_node_names=True) == ";"
         p.name = "abc"
-        assert p.get_newick(with_node_names=True) == "abc;"
+        assert p.get_newick(with_node_names=True, with_root_name=True) == "abc;"
         p.length = 3
         assert (
-            p.get_newick(with_node_names=True, with_distances=True) == "abc:3;"
+            p.get_newick(with_node_names=True, with_distances=True, with_root_name=True)
+            == "abc:3;"
         )  # don't suppress branch from root
         q = PhyloNode()
         p.append(q)
-        assert p.get_newick(with_node_names=True, with_distances=True) == "()abc:3;"
+        assert (
+            p.get_newick(with_node_names=True, with_distances=True, with_root_name=True)
+            == "()abc:3;"
+        )
         r = PhyloNode()
         q.append(r)
-        assert p.get_newick(with_node_names=True, with_distances=True) == "(())abc:3;"
+        assert (
+            p.get_newick(with_node_names=True, with_distances=True, with_root_name=True)
+            == "(())abc:3;"
+        )
         r.name = "xyz"
         assert (
-            p.get_newick(with_node_names=True, with_distances=True) == "((xyz))abc:3;"
+            p.get_newick(with_node_names=True, with_distances=True, with_root_name=True)
+            == "((xyz))abc:3;"
         )
         q.length = 2
         assert (
-            p.get_newick(with_node_names=True, with_distances=True) == "((xyz):2)abc:3;"
+            p.get_newick(with_node_names=True, with_distances=True, with_root_name=True)
+            == "((xyz):2)abc:3;"
         )