Repository: jjmccollum/teiphy

Issue:
Add --version argument to app
Per the first round of reviewer comments on the paper (https://github.com/openjournals/joss-reviews/issues/4879), it would be convenient to support a `--version` argument for the command-line interface that returns the current version of the code.

This should be straightforward, but in the interest of preventing oversight in future updates, it would be nice if we only had to update the version number of the project in one place (specifically, in `pyproject.toml`). @rbturnbull, do you know if there's a preferred way of reading a package's version number within Python for this purpose? I'm seeing several suggestions in the thread at https://github.com/python-poetry/poetry/issues/273, so I wanted to see how you approach this problem.

I've opened a `code-revision` branch where we can make this change.

Discussion:

Patch:
diff --git a/.github/workflows/raxml.yml b/.github/workflows/raxml.yml
new file mode 100644
index 0000000..a93dda3
--- /dev/null
+++ b/.github/workflows/raxml.yml
@@ -0,0 +1,36 @@
+name: raxml
+
+on: [push]
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["3.10"]
+    steps:
+    - uses: actions/checkout@v3
+    - name: Install poetry
+      run: pipx install poetry
+    - name: Install dependencies for Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v3
+      with:
+        python-version: ${{ matrix.python-version }}
+        cache: 'poetry'
+    - run: poetry install
+    - name: Testing
+      run: |
+        poetry run teiphy -t reconstructed -t defective -t orthographic -m overlap -m lac -s"*" -s T --fill-correctors --no-labels --states-present example/ubs_ephesians.xml raxml_example.phy
+        poetry run teiphy -t reconstructed -t defective -t orthographic -m overlap -m lac -s"*" -s T --fill-correctors --no-labels --states-present example/ubs_ephesians.xml raxml_example.fasta
+    - name: Install Phylogenetics Package
+      run: |
+        git clone https://github.com/stamatak/standard-RAxML
+        cd standard-RAxML
+        make -f Makefile.gcc
+        rm *.o
+    - name: Phylogenetics Run
+      run: |
+        cd standard-RAxML
+        ./raxmlHPC -p 12345 -m MULTIGAMMA -s  ../raxml_example.phy -K MK -n T1
+        ./raxmlHPC -p 12345 -m MULTIGAMMA -s  ../raxml_example.fasta -K MK -n T2
+
diff --git a/poetry.lock b/poetry.lock
index 7a46a65..0f5e5ed 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -150,7 +150,7 @@ optional = false
 python-versions = "*"
 
 [package.extras]
-test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
+test = ["hypothesis (==3.55.3)", "flake8 (==3.7.8)"]
 
 [[package]]
 name = "coverage"
@@ -205,7 +205,7 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "exceptiongroup"
-version = "1.0.0rc9"
+version = "1.0.0"
 description = "Backport of PEP 654 (exception groups)"
 category = "dev"
 optional = false
@@ -239,7 +239,7 @@ testing = ["covdefaults (>=2.2)", "coverage (>=6.4.2)", "pytest (>=7.1.2)", "pyt
 
 [[package]]
 name = "identify"
-version = "2.5.7"
+version = "2.5.8"
 description = "File identification library for Python"
 category = "dev"
 optional = false
@@ -460,7 +460,7 @@ sphinx = ["sphinx-book-theme", "Sphinx (>=1.7)", "myst-parser", "moto", "mock",
 
 [[package]]
 name = "nbconvert"
-version = "7.2.2"
+version = "7.2.3"
 description = "Converting Jupyter Notebooks"
 category = "dev"
 optional = false
@@ -636,8 +636,8 @@ optional = false
 python-versions = ">=3.6"
 
 [package.extras]
-dev = ["pre-commit", "tox"]
-testing = ["pytest", "pytest-benchmark"]
+testing = ["pytest-benchmark", "pytest"]
+dev = ["tox", "pre-commit"]
 
 [[package]]
 name = "pre-commit"
@@ -976,8 +976,8 @@ optional = false
 python-versions = ">=3.5"
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
 test = ["pytest"]
+lint = ["docutils-stubs", "mypy", "flake8"]
 
 [[package]]
 name = "sphinxcontrib-bibtex"
@@ -1003,8 +1003,8 @@ optional = false
 python-versions = ">=3.5"
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
 test = ["pytest"]
+lint = ["docutils-stubs", "mypy", "flake8"]
 
 [[package]]
 name = "sphinxcontrib-htmlhelp"
@@ -1015,8 +1015,8 @@ optional = false
 python-versions = ">=3.6"
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
-test = ["pytest", "html5lib"]
+test = ["html5lib", "pytest"]
+lint = ["docutils-stubs", "mypy", "flake8"]
 
 [[package]]
 name = "sphinxcontrib-jsmath"
@@ -1027,7 +1027,7 @@ optional = false
 python-versions = ">=3.5"
 
 [package.extras]
-test = ["pytest", "flake8", "mypy"]
+test = ["mypy", "flake8", "pytest"]
 
 [[package]]
 name = "sphinxcontrib-qthelp"
@@ -1038,8 +1038,8 @@ optional = false
 python-versions = ">=3.5"
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
 test = ["pytest"]
+lint = ["docutils-stubs", "mypy", "flake8"]
 
 [[package]]
 name = "sphinxcontrib-serializinghtml"
@@ -1205,10 +1205,7 @@ cfgv = []
 charset-normalizer = []
 click = []
 colorama = []
-commonmark = [
-    {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
-    {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
-]
+commonmark = []
 coverage = []
 defusedxml = []
 distlib = []
@@ -1284,10 +1281,7 @@ sphinxcontrib-qthelp = []
 sphinxcontrib-serializinghtml = []
 text-unidecode = []
 tinycss2 = []
-toml = [
-    {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
-    {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
-]
+toml = []
 tomli = []
 tornado = []
 traitlets = []
diff --git a/pyproject.toml b/pyproject.toml
index 78e8b2a..c3148e4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,11 @@ classifiers = [
     "License :: OSI Approved :: MIT License",
     "Intended Audience :: Science/Research",
     "Topic :: Software Development :: Libraries :: Python Modules",
+    "Programming Language :: Python :: 3",
+    "Operating System :: OS Independent",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
 ]
 
 
diff --git a/teiphy/collation.py b/teiphy/collation.py
index f615e01..2bd3ad3 100644
--- a/teiphy/collation.py
+++ b/teiphy/collation.py
@@ -324,28 +324,6 @@ class Collation:
     #     nexus_equate_mapping = {t: possible_symbols[i] for i, t in enumerate(reading_ind_tuples)}
     #     return nexus_equates, nexus_equate_mapping
 
-    def get_hennig86_symbols(self):
-        """Returns a list of one-character symbols needed to represent the states of all substantive readings in Hennig86 format.
-
-        The number of symbols equals the maximum number of substantive readings at any variation unit.
-
-        Returns:
-            A list of individual characters representing states in readings.
-        """
-        possible_symbols = (
-            list(string.digits) + list(string.ascii_uppercase)[:22]
-        )  # NOTE: the maximum number of symbols allowed in Hennig86 format is 32
-        # The number of symbols needed is equal to the length of the longest substantive reading vector:
-        nsymbols = 0
-        # If there are no witnesses, then no symbols are needed at all:
-        if len(self.witnesses) == 0:
-            return []
-        wit_id = self.witnesses[0].id
-        for rdg_support in self.readings_by_witness[wit_id]:
-            nsymbols = max(nsymbols, len(rdg_support))
-        hennig86_symbols = possible_symbols[:nsymbols]
-        return hennig86_symbols
-
     def to_nexus(
         self,
         file_addr: Union[Path, str],
@@ -485,6 +463,28 @@ class Collation:
             f.write("End;")
         return
 
+    def get_hennig86_symbols(self):
+        """Returns a list of one-character symbols needed to represent the states of all substantive readings in Hennig86 format.
+
+        The number of symbols equals the maximum number of substantive readings at any variation unit.
+
+        Returns:
+            A list of individual characters representing states in readings.
+        """
+        possible_symbols = (
+            list(string.digits) + list(string.ascii_uppercase)[:22]
+        )  # NOTE: the maximum number of symbols allowed in Hennig86 format is 32
+        # The number of symbols needed is equal to the length of the longest substantive reading vector:
+        nsymbols = 0
+        # If there are no witnesses, then no symbols are needed at all:
+        if len(self.witnesses) == 0:
+            return []
+        wit_id = self.witnesses[0].id
+        for rdg_support in self.readings_by_witness[wit_id]:
+            nsymbols = max(nsymbols, len(rdg_support))
+        hennig86_symbols = possible_symbols[:nsymbols]
+        return hennig86_symbols
+
     def to_hennig86(self, file_addr: Union[Path, str]):
         """Writes this Collation to a file in Hennig86 format with the given address.
         Note that because Hennig86 format does not support NEXUS-style ambiguities, such ambiguities will be treated as missing data.
@@ -541,6 +541,144 @@ class Collation:
             f.write(";")
         return
 
+    def get_phylip_symbols(self):
+        """Returns a list of one-character symbols needed to represent the states of all substantive readings in PHYLIP format.
+
+        The number of symbols equals the maximum number of substantive readings at any variation unit.
+
+        Returns:
+            A list of individual characters representing states in readings.
+        """
+        possible_symbols = (
+            list(string.digits) + list(string.ascii_lowercase)[:22]
+        )  # NOTE: for RAxML, multistate characters with an alphabet sizes up to 32 are supported
+        # The number of symbols needed is equal to the length of the longest substantive reading vector:
+        nsymbols = 0
+        # If there are no witnesses, then no symbols are needed at all:
+        if len(self.witnesses) == 0:
+            return []
+        wit_id = self.witnesses[0].id
+        for rdg_support in self.readings_by_witness[wit_id]:
+            nsymbols = max(nsymbols, len(rdg_support))
+        phylip_symbols = possible_symbols[:nsymbols]
+        return phylip_symbols
+
+    def to_phylip(self, file_addr: Union[Path, str]):
+        """Writes this Collation to a file in PHYLIP format with the given address.
+        Note that because PHYLIP format does not support NEXUS-style ambiguities, such ambiguities will be treated as missing data.
+
+        Args:
+            file_addr: A string representing the path to an output file.
+        """
+        # Start by calculating the values we will be using here:
+        ntax = len(self.witnesses)
+        nchar = (
+            len(self.readings_by_witness[self.witnesses[0].id]) if ntax > 0 else 0
+        )  # if the number of taxa is 0, then the number of characters is irrelevant
+        taxlabels = []
+        for wit in self.witnesses:
+            taxlabel = wit.id
+            # Then replace any disallowed characters in the string with an underscore:
+            taxlabel = slugify(taxlabel, lowercase=False, separator='_')
+            taxlabels.append(taxlabel)
+        max_taxlabel_length = max(
+            [len(taxlabel) for taxlabel in taxlabels]
+        )  # keep track of the longest taxon label for tabular alignment purposes
+        missing_symbol = '?'
+        symbols = self.get_phylip_symbols()
+        with open(file_addr, "w", encoding="ascii") as f:
+            # Write the dimensions:
+            f.write("%d %d\n" % (ntax, nchar))
+            # Now write the matrix:
+            for i, wit in enumerate(self.witnesses):
+                taxlabel = taxlabels[i]
+                # Add enough space after this label ensure that all sequences are nicely aligned:
+                sequence = taxlabel + (" " * (max_taxlabel_length - len(taxlabel))) + "\t"
+                for rdg_support in self.readings_by_witness[wit.id]:
+                    # If this reading is lacunose in this witness, then use the missing character:
+                    if sum(rdg_support) == 0:
+                        sequence += missing_symbol
+                        continue
+                    rdg_inds = [
+                        i for i, w in enumerate(rdg_support) if w > 0
+                    ]  # the index list consists of the indices of all readings with any degree of certainty assigned to them
+                    # For singleton readings, just print the symbol:
+                    if len(rdg_inds) == 1:
+                        sequence += symbols[rdg_inds[0]]
+                        continue
+                    # For multiple readings, print the missing symbol:
+                    sequence += missing_symbol
+                f.write("%s\n" % (sequence))
+        return
+
+    def get_fasta_symbols(self):
+        """Returns a list of one-character symbols needed to represent the states of all substantive readings in FASTA format.
+
+        The number of symbols equals the maximum number of substantive readings at any variation unit.
+
+        Returns:
+            A list of individual characters representing states in readings.
+        """
+        possible_symbols = (
+            list(string.digits) + list(string.ascii_lowercase)[:22]
+        )  # NOTE: for RAxML, multistate characters with an alphabet sizes up to 32 are supported
+        # The number of symbols needed is equal to the length of the longest substantive reading vector:
+        nsymbols = 0
+        # If there are no witnesses, then no symbols are needed at all:
+        if len(self.witnesses) == 0:
+            return []
+        wit_id = self.witnesses[0].id
+        for rdg_support in self.readings_by_witness[wit_id]:
+            nsymbols = max(nsymbols, len(rdg_support))
+        fasta_symbols = possible_symbols[:nsymbols]
+        return fasta_symbols
+
+    def to_fasta(self, file_addr: Union[Path, str]):
+        """Writes this Collation to a file in FASTA format with the given address.
+        Note that because FASTA format does not support NEXUS-style ambiguities, such ambiguities will be treated as missing data.
+
+        Args:
+            file_addr: A string representing the path to an output file.
+        """
+        # Start by calculating the values we will be using here:
+        ntax = len(self.witnesses)
+        nchar = (
+            len(self.readings_by_witness[self.witnesses[0].id]) if ntax > 0 else 0
+        )  # if the number of taxa is 0, then the number of characters is irrelevant
+        taxlabels = []
+        for wit in self.witnesses:
+            taxlabel = wit.id
+            # Then replace any disallowed characters in the string with an underscore:
+            taxlabel = slugify(taxlabel, lowercase=False, separator='_')
+            taxlabels.append(taxlabel)
+        max_taxlabel_length = max(
+            [len(taxlabel) for taxlabel in taxlabels]
+        )  # keep track of the longest taxon label for tabular alignment purposes
+        missing_symbol = '?'
+        symbols = self.get_fasta_symbols()
+        with open(file_addr, "w", encoding="ascii") as f:
+            # Now write the matrix:
+            for i, wit in enumerate(self.witnesses):
+                taxlabel = taxlabels[i]
+                # Add enough space after this label ensure that all sequences are nicely aligned:
+                sequence = ">%s\n" % taxlabel
+                for rdg_support in self.readings_by_witness[wit.id]:
+                    # If this reading is lacunose in this witness, then use the missing character:
+                    if sum(rdg_support) == 0:
+                        sequence += missing_symbol
+                        continue
+                    rdg_inds = [
+                        i for i, w in enumerate(rdg_support) if w > 0
+                    ]  # the index list consists of the indices of all readings with any degree of certainty assigned to them
+                    # For singleton readings, just print the symbol:
+                    if len(rdg_inds) == 1:
+                        sequence += symbols[rdg_inds[0]]
+                        continue
+                    # For multiple readings, print the missing symbol:
+                    sequence += missing_symbol
+                f.write("%s\n" % (sequence))
+        return
+
     def to_numpy(self, split_missing: bool = True):
         """Returns this Collation in the form of a NumPy array, along with arrays of its row and column labels.
 
@@ -820,7 +958,7 @@ class Collation:
             split_missing (bool, optional): An optional flag indicating whether to treat
                 missing characters/variation units as having a contribution of 1 split over all states/readings;
                 if False, then missing data is ignored (i.e., all states are 0).
-                Not applicable for NEXUS, HENNIG86, or STEMMA format.
+                Not applicable for NEXUS, HENNIG86, PHYLIP, FASTA, or STEMMA format.
                 Default value is True.
             char_state_labels (bool, optional): An optional flag indicating whether to print
                 the CharStateLabels block in NEXUS output.
@@ -851,6 +989,12 @@ class Collation:
         if format == format.HENNIG86:
             return self.to_hennig86(file_addr)
 
+        if format == format.PHYLIP:
+            return self.to_phylip(file_addr)
+
+        if format == format.FASTA:
+            return self.to_fasta(file_addr)
+
         if format == Format.CSV:
             return self.to_csv(file_addr, split_missing=split_missing)
 
diff --git a/teiphy/format.py b/teiphy/format.py
index fd6654d..d122a24 100644
--- a/teiphy/format.py
+++ b/teiphy/format.py
@@ -8,6 +8,8 @@ class FormatUnknownException(Exception):
 class Format(Enum):
     NEXUS = 'NEXUS'
     HENNIG86 = 'HENNIG86'
+    PHYLIP = 'PHYLIP'
+    FASTA = 'FASTA'
     CSV = 'CSV'
     TSV = 'TSV'
     EXCEL = 'EXCEL'
@@ -19,6 +21,10 @@ class Format(Enum):
             ".nex": cls.NEXUS,
             ".nexus": cls.NEXUS,
             ".nxs": cls.NEXUS,
+            ".ph": cls.PHYLIP,
+            ".phy": cls.PHYLIP,
+            ".fa": cls.FASTA,
+            ".fasta": cls.FASTA,
             ".tnt": cls.HENNIG86,
             ".csv": cls.CSV,
             ".tsv": cls.TSV,
diff --git a/teiphy/main.py b/teiphy/main.py
index 8d25a32..a8fec14 100644
--- a/teiphy/main.py
+++ b/teiphy/main.py
@@ -1,4 +1,5 @@
 from typing import List  # for list-like inputs
+from importlib.metadata import version # for checking package version
 from pathlib import Path  # for validating file address inputs
 from lxml import etree as et  # for parsing XML input
 import typer
@@ -9,6 +10,11 @@ from .collation import Collation
 
 app = typer.Typer(rich_markup_mode="rich")
 
+def version_callback(value: bool):
+    if value:
+        teiphy_version = version("teiphy")
+        typer.echo(teiphy_version)
+        raise typer.Exit()
 
 @app.command()
 def to_file(
@@ -44,6 +50,12 @@ def to_file(
         help="Use the missing symbol instead of Equate symbols (and thus treat all ambiguities as missing data) in NEXUS output; this option is only applied if the --states-present option is also set.",
     ),
     verbose: bool = typer.Option(False, help="Enable verbose logging (mostly for debugging purposes)."),
+    version: bool = typer.Option(
+        False,
+        callback=version_callback,
+        is_eager=True,
+        help="Print the current version.",
+    ),
     format: Format = typer.Option(None, case_sensitive=False, help="The output format."),
     input: Path = typer.Argument(
         ...,


Test patch:
diff --git a/tests/test_collation.py b/tests/test_collation.py
index 251aab4..484be1f 100644
--- a/tests/test_collation.py
+++ b/tests/test_collation.py
@@ -248,6 +248,26 @@ class CollationOutputTestCase(unittest.TestCase):
         hennig86_symbols = empty_collation.get_hennig86_symbols()
         self.assertEqual(hennig86_symbols, [])
 
+    def test_get_phylip_symbols(self):
+        phylip_symbols = self.collation.get_phylip_symbols()
+        self.assertEqual(phylip_symbols, ["0", "1", "2", "3", "4", "5"])
+
+    def test_get_phylip_symbols_empty(self):
+        empty_xml = et.fromstring("<TEI/>")
+        empty_collation = Collation(empty_xml)
+        phylip_symbols = empty_collation.get_phylip_symbols()
+        self.assertEqual(phylip_symbols, [])
+
+    def test_get_fasta_symbols(self):
+        fasta_symbols = self.collation.get_fasta_symbols()
+        self.assertEqual(fasta_symbols, ["0", "1", "2", "3", "4", "5"])
+
+    def test_get_fasta_symbols_empty(self):
+        empty_xml = et.fromstring("<TEI/>")
+        empty_collation = Collation(empty_xml)
+        fasta_symbols = empty_collation.get_fasta_symbols()
+        self.assertEqual(fasta_symbols, [])
+
     def test_to_numpy_ignore_missing(self):
         matrix, reading_labels, witness_labels = self.collation.to_numpy(split_missing=False)
         self.assertTrue(
diff --git a/tests/test_format.py b/tests/test_format.py
index 77845fb..9fd0611 100644
--- a/tests/test_format.py
+++ b/tests/test_format.py
@@ -8,6 +8,10 @@ class FormatTestCase(unittest.TestCase):
         self.assertEqual(Format.infer(".nex"), Format.NEXUS)
         self.assertEqual(Format.infer(".nxs"), Format.NEXUS)
         self.assertEqual(Format.infer(".nexus"), Format.NEXUS)
+        self.assertEqual(Format.infer(".ph"), Format.PHYLIP)
+        self.assertEqual(Format.infer(".phy"), Format.PHYLIP)
+        self.assertEqual(Format.infer(".fa"), Format.FASTA)
+        self.assertEqual(Format.infer(".fasta"), Format.FASTA)
         self.assertEqual(Format.infer(".tnt"), Format.HENNIG86)
         self.assertEqual(Format.infer(".csv"), Format.CSV)
         self.assertEqual(Format.infer(".tsv"), Format.TSV)
diff --git a/tests/test_main.py b/tests/test_main.py
index 27adc3c..51a2cb2 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -16,6 +16,12 @@ no_dates_example = test_dir / "no_dates_example.xml"
 some_dates_example = test_dir / "some_dates_example.xml"
 
 
+def test_version():
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        result = runner.invoke(app, ["--version"])
+        assert result.stdout != ""
+
+
 def test_non_xml_input():
     with tempfile.TemporaryDirectory() as tmp_dir:
         output = Path(tmp_dir) / "test.nexus"
@@ -104,6 +110,54 @@ def test_to_hennig86():
         assert "xread" in text
 
 
+def test_to_phylip():
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        output = Path(tmp_dir) / "test.phy"
+        result = runner.invoke(
+            app,
+            [
+                "-t reconstructed",
+                "-t defective",
+                "-t orthographic",
+                "-m lac",
+                "-m overlap",
+                "-s *",
+                "-s T",
+                "--fill-correctors",
+                str(input_example),
+                str(output),
+            ],
+        )
+        assert result.exit_code == 0
+        assert output.exists()
+        text = output.read_text(encoding="ascii")
+        assert text.startswith("38 40")
+
+
+def test_to_fasta():
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        output = Path(tmp_dir) / "test.fa"
+        result = runner.invoke(
+            app,
+            [
+                "-t reconstructed",
+                "-t defective",
+                "-t orthographic",
+                "-m lac",
+                "-m overlap",
+                "-s *",
+                "-s T",
+                "--fill-correctors",
+                str(input_example),
+                str(output),
+            ],
+        )
+        assert result.exit_code == 0
+        assert output.exists()
+        text = output.read_text(encoding="ascii")
+        assert text.startswith(">UBS")
+
+
 def test_to_csv():
     with tempfile.TemporaryDirectory() as tmp_dir:
         output = Path(tmp_dir) / "test.csv"