Repository: cogent3/cogent3
Pull request #1573 (2023-09-18 06:22:12)

FIX: make_tree() mechanism now also supports NHX format


Patch:
diff --git a/src/cogent3/parse/newick.py b/src/cogent3/parse/newick.py
index 4c30018b5..4ae28636d 100644
--- a/src/cogent3/parse/newick.py
+++ b/src/cogent3/parse/newick.py
@@ -60,27 +60,35 @@ class _Tokeniser(object):
         return TreeParseError(msg + ". " + detail)
 
     def tokens(self):
-        closing_quote_token = None
         column = 0
         line = 0
         text = None
         closing_quote_token = None
         in_comment = False
-        for token in re.split("""([\\t ]+|\\n|''|""|[]['"(),:;])""", self.text) + [EOT]:
+        comment = []
+        splits = re.split("""([\\t ]+|\\n|''|""|[]['"(),:;\[\]])""", self.text)
+        for token in splits + [EOT]:
             label_complete = False
             token_consumed = True
             self.token = token
             column += len(token or "")
             self.posn = (line, column)
 
-            if token == "":
-                pass
-            elif in_comment:
+            if token == "":  # we have to allow None to pass this
+                continue
+
+            if in_comment:
                 if token is EOT:
                     raise self.error("Ended with unclosed comment")
                 if token == "]":
                     in_comment = False
-            elif closing_quote_token:
+                    text = "".join(comment)
+                    comment = []
+                else:
+                    comment.append(token)
+                    continue
+
+            if closing_quote_token:
                 if token is EOT:
                     raise self.error("Text ended inside quoted label")
                 if token == "\n":
@@ -92,7 +100,7 @@ class _Tokeniser(object):
                     if token == closing_quote_token * 2:
                         token = token[0]
                     text += token
-            elif token is EOT or token in "\n[():,;":
+            elif token is EOT or token in "\n[]():,;":
                 if text:
                     text = text.strip()
                     if self.underscore_unmunge and "_" in text:
@@ -103,7 +111,8 @@ class _Tokeniser(object):
                     column = 1
                 elif token == "[":
                     in_comment = True
-                else:
+                    token_consumed = False
+                elif token != "]":
                     token_consumed = False
             elif text is not None:
                 text += token
@@ -151,7 +160,7 @@ def parse_string(text, constructor, **kw):
             try:
                 attributes[attr_name] = attr_cast(token)
             except ValueError:
-                raise tokeniser.error(f"Can't convert {attr_name} '{token}'")
+                raise tokeniser.error(f"Can't convert {attr_name!r} '{token}'")
             expected_attribute = None
         elif token == "(":
             if children is not None:
@@ -164,29 +173,27 @@ def parse_string(text, constructor, **kw):
             if "length" in attributes:
                 raise tokeniser.error("Already have a length.")
             expected_attribute = ("length", float)
+        elif token == "[":
+            if "other" in attributes:
+                raise tokeniser.error("Already have a 'other'.")
+            expected_attribute = ("other", lambda x: x.split(","))
         elif token in [")", ";", ",", EOT]:
             nodes.append(constructor(children, name, attributes))
             children = name = expected_attribute = None
             attributes = {}
             if token in sentinals:
-                if stack:
-                    children = nodes
-                    (nodes, sentinals, attributes) = stack.pop()
-                else:
+                if not stack:
                     break
-            elif token == "," and ")" in sentinals:
-                pass
-            else:
+                children = nodes
+                (nodes, sentinals, attributes) = stack.pop()
+            elif token != "," or ")" not in sentinals:
                 raise tokeniser.error(
-                    "Was expecting to end with %s"
-                    % " or ".join([repr(s) for s in sentinals])
+                    f'Was expecting to end with {" or ".join([repr(s) for s in sentinals])}'
                 )
-        else:
-            if name is not None:
-                raise tokeniser.error(f"Already have a name '{name}' for this node.")
-            elif attributes:
-                raise tokeniser.error("name should come before length.")
+        elif name is None:
             name = token
+        else:
+            raise tokeniser.error(f"Already have a name {name!r} for this node.")
     assert not stack, stack
     assert len(nodes) == 1, len(nodes)
     return nodes[0]


Test patch:
diff --git a/tests/test_core/test_tree.py b/tests/test_core/test_tree.py
index 0acfc36d3..baa764b58 100644
--- a/tests/test_core/test_tree.py
+++ b/tests/test_core/test_tree.py
@@ -1728,14 +1728,6 @@ class TreeInterfaceForLikelihoodFunction(TestCase):
         ]:
             self.assertEqual(tree.get_edge_names(a, b, True, False, outgroup), result)
 
-    def test_parser(self):
-        """nasty newick"""
-        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 = self._maketree(nasty)
-        tidied = tree.get_newick(with_distances=1)
-        self.assertEqual(tidied, nice)
-
     # Likelihood Function Interface
 
     def test_get_edge_names(self):
@@ -2224,3 +2216,13 @@ def test_child_parent_map(nwk):
     assert child_2_parent["a"] == "root"
     edge = random.choice(all_edges)
     assert child_2_parent[edge.name] == edge.parent.name
+
+
+def test_parser():
+    """nasty newick"""
+    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=1)
+    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 3e21e22b5..3cf1314d2 100644
--- a/tests/test_parse/test_tree.py
+++ b/tests/test_parse/test_tree.py
@@ -274,6 +274,15 @@ class PhyloNodeTests(TestCase):
         self.assertEqual(str(p), "((xyz):2)abc:3;")
 
 
+def test_make_tree_simple():
+    from cogent3 import make_tree
+
+    tree = make_tree(treestring="(a:0.4,b:0.5,c:0.6)")
+    assert tree.get_node_matching_name("a").length == 0.4
+    assert tree.get_node_matching_name("b").length == 0.5
+    assert tree.get_node_matching_name("c").length == 0.6
+
+
 def test_stores_nhx_data(DATA_DIR):
     """capture extended newick data into <node>.params['other']"""
     with open(DATA_DIR / "nhx.tree") as path:
@@ -290,3 +299,41 @@ def test_stores_nhx_data(DATA_DIR):
             'sDF2_N="6.49"',
             'sN="120.33"',
         }
+
+
+def test_subtree_nhx_data(DATA_DIR):
+    """capture extended newick data into <node>.params['other']"""
+    with open(DATA_DIR / "nhx.tree") as path:
+        tree = DndParser(path)
+
+    subtree = tree.get_sub_tree(["Seal", "Mouse", "Rat", "Platypus", "Opossum"])
+    rodent = subtree.get_connecting_node("Mouse", "Rat")
+    assert set(rodent.params["other"]) == {
+        '&sCF="89.13"',
+        'sCF/sDF1/sDF2="89.13/5.49/5.38"',
+        'sCF_N="107.21"',
+        'sCF_N/sDF1_N/sDF2_N="107.21/6.63/6.49"',
+        'sDF1="5.49"',
+        'sDF1_N="6.63"',
+        'sDF2="5.38"',
+        'sDF2_N="6.49"',
+        'sN="120.33"',
+    }
+
+
+def test_make_tree_with_nhx(DATA_DIR):
+    """capture extended newick data into <node>.params['other']"""
+    from cogent3 import make_tree
+
+    nwk = (
+        '(t1:0.1,(t2:0.2,t3:0.3):0.4[&sCF="81.73"],'
+        '((t4:0.5,t5:0.6):0.7[&sCF="50.68",sDF1="24.42"],'
+        't6:0.8):0.9[&sCF="77.1",sDF1="11.53"]);'
+    )
+    got = make_tree(nwk)
+    vert = got.get_connecting_node("t2", "t3")
+    assert set(vert.params["other"]) == {'&sCF="81.73"'}
+    vert = got.get_connecting_node("t4", "t5")
+    assert set(vert.params["other"]) == {'&sCF="50.68"', 'sDF1="24.42"'}
+    vert = got.get_connecting_node("t5", "t6")
+    assert set(vert.params["other"]) == {'&sCF="77.1"', 'sDF1="11.53"'}