Repository: cogent3/cogent3
Pull request #2283 (2025-04-22 00:35:26)

MAINT: refactor format writer
[NEW] added optional argument `order` to `clustal_from_alignment` to allow
     reordering of sequences in the output.

[CHANGED] clustal_from_alignment now takes a dictionary of sequences
     instead of an alignment object. This matches the other sequence
     format writers.

[CHANGED] renamed `cogent3.format.alignment` to `cogent3.format.sequence`
     to better reflect its purpose.

## Summary by Sourcery

Refactor the 'clustal_from_alignment' function to accept a dictionary of sequences and add an optional 'order' argument for sequence reordering. Rename 'cogent3.format.alignment' to 'cogent3.format.sequence' for clarity.

New Features:
- Added an optional 'order' argument to the 'clustal_from_alignment' function to allow reordering of sequences in the output.

Enhancements:
- Refactored 'clustal_from_alignment' to accept a dictionary of sequences instead of an alignment object, aligning it with other sequence format writers.

Chores:
- Renamed 'cogent3.format.alignment' to 'cogent3.format.sequence' to better reflect its purpose.


Patch:
diff --git a/changelog.d/20250406_125550_Gavin.Huttley.md b/changelog.d/20250406_125550_Gavin.Huttley.md
index 6ec79e4cb..a1fd80ac5 100644
--- a/changelog.d/20250406_125550_Gavin.Huttley.md
+++ b/changelog.d/20250406_125550_Gavin.Huttley.md
@@ -53,3 +53,5 @@ Uncomment the section that is right (remove the HTML comment wrapper).
   - `subset()`
   - `update()`
   - `union()`
+- `cogent3.format.alignment` has been renamed `cogent3.format.sequence`.
+   This is a backwards incompatible change!
diff --git a/src/cogent3/app/io.py b/src/cogent3/app/io.py
index 22ddcd12c..901854d5f 100644
--- a/src/cogent3/app/io.py
+++ b/src/cogent3/app/io.py
@@ -21,7 +21,7 @@ from cogent3.core.profile import (
     make_pssm_from_tabular,
 )
 from cogent3.evolve.fast_distance import DistanceMatrix
-from cogent3.format.alignment import FORMATTERS
+from cogent3.format.sequence import FORMATTERS
 from cogent3.parse.sequence import PARSERS
 from cogent3.util.deserialise import deserialise_object
 from cogent3.util.table import Table
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index 0ca44b755..f7de4c3e7 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -69,12 +69,12 @@ from cogent3.core.info import Info as InfoClass
 from cogent3.core.location import FeatureMap, IndelMap
 from cogent3.core.profile import PSSM, MotifCountsArray
 from cogent3.core.sequence import ArraySequence, Sequence, frac_same
-
-# which is a circular import otherwise.
-from cogent3.format.alignment import save_to_filename
 from cogent3.format.fasta import seqs_to_fasta
 from cogent3.format.nexus import nexus_from_alignment
 from cogent3.format.phylip import alignment_to_phylip
+
+# which is a circular import otherwise.
+from cogent3.format.sequence import save_to_filename
 from cogent3.maths.stats.number import CategoryCounter
 from cogent3.util import progress_display as UI
 from cogent3.util import warning as c3warn
@@ -501,7 +501,7 @@ class _SequenceCollectionBase:
 
     def __str__(self) -> str:  # ported
         """Returns self in FASTA-format, respecting name order."""
-        from cogent3.format.alignment import FORMATTERS
+        from cogent3.format.sequence import FORMATTERS
 
         return FORMATTERS["fasta"](self.to_dict())
 
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index f6b3fa087..299aef44c 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -40,9 +40,9 @@ from cogent3.core.location import (
     IndelMap,
 )
 from cogent3.core.profile import PSSM, MotifCountsArray, MotifFreqsArray, load_pssm
-from cogent3.format.alignment import save_to_filename
 from cogent3.format.fasta import seqs_to_fasta
 from cogent3.format.phylip import alignment_to_phylip
+from cogent3.format.sequence import save_to_filename
 from cogent3.maths.stats.number import CategoryCounter
 from cogent3.util import progress_display as UI
 from cogent3.util import warning as c3warn
@@ -2142,7 +2142,7 @@ class SequenceCollection:
 
     def __str__(self) -> str:
         """Returns self in FASTA-format, respecting name order."""
-        from cogent3.format.alignment import FORMATTERS
+        from cogent3.format.sequence import FORMATTERS
 
         return FORMATTERS["fasta"](self.to_dict())
 
diff --git a/src/cogent3/format/clustal.py b/src/cogent3/format/clustal.py
index c380398a5..9fb47dd96 100644
--- a/src/cogent3/format/clustal.py
+++ b/src/cogent3/format/clustal.py
@@ -4,14 +4,15 @@ Writer for Clustal format.
 """
 
 import os
-from copy import copy
-
-import cogent3
 
 _NEW_TYPE = "COGENT3_NEW_TYPE" in os.environ
 
 
-def clustal_from_alignment(aln, wrap=None):
+def clustal_from_alignment(
+    data: dict[str, str],
+    wrap: int | None = None,
+    order: list[str] | None = None,
+) -> str:
     """
     Parameters
     ----------
@@ -25,37 +26,25 @@ def clustal_from_alignment(aln, wrap=None):
     -------
     Returns a string in Clustal format
     """
-    if not aln:
+    if not data:
         return ""
 
     # get seq output order
-    try:
-        order = aln.RowOrder
-    except:
-        order = list(aln.keys())
-        order.sort()
+    order = order or list(data)
 
-    seqs = cogent3.make_unaligned_seqs(aln, moltype="text")
     clustal_list = ["CLUSTAL\n"]
-
-    if seqs.is_ragged():
-        raise ValueError(
-            "Sequences in alignment are not all the same length."
-            + "Cannot generate Clustal format.",
-        )
-
-    aln_len = len(seqs.seqs[0])
-    # Get all labels
-    labels = copy(seqs.names)
+    aln_len = len(data[order[0]])
+    for l, s in data.items():
+        if len(s) != aln_len:
+            msg = f"length of {l!r}={len(s)} != {aln_len}"
+            raise ValueError(msg)
 
     # Find all label lengths in order to get padding.
-    label_lengths = [len(l) for l in labels]
-    label_max = max(label_lengths)
+    label_max = len(max(order, key=len))
     max_spaces = label_max + 4
 
     # Get ordered seqs
-    named_seqs = seqs.seqs if _NEW_TYPE else seqs.named_seqs
-    ordered_seqs = [named_seqs[label] for label in order]
+    ordered_seqs = [data[label] for label in order]
 
     if wrap is not None:
         curr_ix = 0


Test patch:
diff --git a/src/cogent3/format/alignment.py b/src/cogent3/format/sequence.py
similarity index 100%
rename from src/cogent3/format/alignment.py
rename to src/cogent3/format/sequence.py
diff --git a/tests/test_format/test_clustal.py b/tests/test_format/test_clustal.py
index 916ffef0b..fe5136742 100644
--- a/tests/test_format/test_clustal.py
+++ b/tests/test_format/test_clustal.py
@@ -29,7 +29,6 @@ class ClustalTests(TestCase):
             moltype="text",
         )
         self.alignment_order = ["2nd", "4th", "3rd", "1st"]
-        self.alignment_object.RowOrder = self.alignment_order
 
         self.clustal_with_label = """CLUSTAL
 
@@ -88,10 +87,14 @@ class ClustalTests(TestCase):
     def test_clustal_from_alignment_reordered(self):
         """should return correct clustal string."""
         assert (
-            clustal_from_alignment(self.alignment_object)
+            clustal_from_alignment(self.alignment_dict, order=self.alignment_order)
             == self.clustal_with_label_reordered
         )
         assert (
-            clustal_from_alignment(self.alignment_object, wrap=2)
+            clustal_from_alignment(
+                self.alignment_dict,
+                order=self.alignment_order,
+                wrap=2,
+            )
             == self.clustal_with_label_lw2_reordered
         )