diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 9ebfcb7..b4b825e 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -68,4 +68,16 @@ jobs: pip install black - name: Lint with Black run: | - black --check . \ No newline at end of file + black --check . + + version-consistency: + name: Version consistency + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + - name: Check version matches across files + run: python scripts/check_version_consistency.py \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index a0757a1..f44c538 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,15 +1,9 @@ include README.md include LICENSE include pyproject.toml -include meta.yaml -include environment.yml -include conda_build_config.yaml -include build.sh -include bld.bat # Include package data recursive-include barcodeforge *.py -recursive-include barcodeforge/assets * # Include tests recursive-include tests *.py diff --git a/barcodeforge/__init__.py b/barcodeforge/__init__.py index 9b102be..a955fda 100644 --- a/barcodeforge/__init__.py +++ b/barcodeforge/__init__.py @@ -1 +1 @@ -__version__ = "1.1.5" +__version__ = "1.2.1" diff --git a/barcodeforge/auspice_tree_to_table.py b/barcodeforge/auspice_tree_to_table.py index 5e94966..f512823 100644 --- a/barcodeforge/auspice_tree_to_table.py +++ b/barcodeforge/auspice_tree_to_table.py @@ -75,6 +75,10 @@ def json_to_tree(json_dict, root=True, parent_cumulative_branch_length=None): if attr != "children": setattr(node, attr, value) + # Default so the child recursion can always read node.cumulative_branch_length, + # even for a node that has children but neither 'attr' (v1) nor 'node_attrs' (v2). + node.cumulative_branch_length = None + # Handle specific attributes like 'num_date', 'div' (cumulative_branch_length), # and 'translations' based on JSON version (v1 uses 'attr', v2 uses 'node_attrs'). if hasattr(node, "attr"): # v1 style @@ -226,8 +230,12 @@ def process_auspice_json( if records: df = pd.DataFrame(records) + # "name" is always emitted first; exclude it from the attribute list so + # `--attributes name` cannot produce two identical 'name' columns. final_columns = ["name"] + [ - attr for attr in attributes_to_export if attr in df.columns + attr + for attr in attributes_to_export + if attr in df.columns and attr != "name" ] # Add any other columns that might have been added but were not in attributes_to_export. for col in df.columns: diff --git a/barcodeforge/cli.py b/barcodeforge/cli.py index b1b714a..1ed181d 100644 --- a/barcodeforge/cli.py +++ b/barcodeforge/cli.py @@ -1,4 +1,5 @@ import os +import shlex import rich_click as click from .format_tree import convert_nexus_to_newick from .utils import ( @@ -166,7 +167,7 @@ def barcode( # Run usher command usher_cmd = ["usher"] if usher_args: - usher_cmd.extend(usher_args.split()) # Split space-separated string of args + usher_cmd.extend(shlex.split(usher_args)) usher_cmd.extend( [ "-t", diff --git a/barcodeforge/format_tree.py b/barcodeforge/format_tree.py index d424542..2e9c6e6 100755 --- a/barcodeforge/format_tree.py +++ b/barcodeforge/format_tree.py @@ -81,7 +81,7 @@ def convert_nexus_to_newick( tree_string_from_file = "" with open(output_file_str, "r") as handle: for line in handle: - l = line.strip("\\n") + l = line.strip() if "(" in l: tree_string_start = l.index("(") tree_string_from_file = l[tree_string_start:] diff --git a/barcodeforge/generate_barcodes.py b/barcodeforge/generate_barcodes.py index 9344115..ee5b03c 100755 --- a/barcodeforge/generate_barcodes.py +++ b/barcodeforge/generate_barcodes.py @@ -19,8 +19,7 @@ def parse_tree_paths(df: pd.DataFrame) -> pd.DataFrame: pd.DataFrame: A DataFrame with clade names as the index and a list of mutations for each clade. """ df = df.set_index("clade") - # Make sure to check with new tree versions, lineages could get trimmed. - df = df.drop_duplicates(keep="last") + df = df[~df.index.duplicated(keep="last")] df["from_tree_root"] = df["from_tree_root"].fillna("") df["from_tree_root"] = df["from_tree_root"].apply( lambda x: x.replace(" ", "").strip(">").split(">") diff --git a/barcodeforge/plot_barcode.py b/barcodeforge/plot_barcode.py index cab40a0..25f9fbf 100755 --- a/barcodeforge/plot_barcode.py +++ b/barcodeforge/plot_barcode.py @@ -1,7 +1,7 @@ """Plot barcode from CSV file.""" import pandas as pd -from .utils import print_info, print_debug +from .utils import print_info, print_debug, print_warning import seaborn as sns import matplotlib.pyplot as plt from matplotlib.colors import ListedColormap @@ -19,6 +19,10 @@ def create_barcode_visualization( df[["Reference", "pos", "alt"]] = df.Mutation.str.extract( r"([A-Za-z]+)(\d+)([A-Za-z]+)" ) + df = df.dropna(subset=["pos"]) + if df.empty: + print_warning("No plottable mutations found; skipping barcode plot.") + return df.pos = df.pos.astype(int) wide = ( df.drop(columns=["Mutation", "z"]) diff --git a/barcodeforge/ref_muts.py b/barcodeforge/ref_muts.py index 909ae62..63c8a6d 100755 --- a/barcodeforge/ref_muts.py +++ b/barcodeforge/ref_muts.py @@ -1,8 +1,15 @@ import pandas as pd +import rich_click as click from Bio import SeqIO from collections import OrderedDict import re -from .utils import print_warning, print_success, print_info, print_debug +from .utils import ( + print_warning, + print_success, + print_info, + print_debug, + print_error, +) def _load_sample_mutations(path): @@ -26,11 +33,7 @@ def _extract_mutations(sample): def _reverse_mutations_to_root(muts): """Reverse the mutations to the root node.""" root_muts = {} - if muts == {}: - root_muts[0] = { - "base": "", - "mut": "", - } + if not muts: return root_muts for value in muts.values(): for i in value: @@ -78,19 +81,20 @@ def _compare_sequences(ref, root): # generate a consensus root sequence def _derive_root_sequence(root_seqs): """Generate a consensus root sequence.""" + if not root_seqs: + print_error( + "Cannot infer a root sequence: none of the samples in the mutations " + "file were found in the alignment FASTA." + ) + raise click.Abort() # for each position in the sequence get the most common nucleotide and add it to the consensus sequence consensus_root = "" for i in range(len(root_seqs[0].seq)): - nucs = [] - for seq in root_seqs: - nucs.append(seq.seq[i]) - # eliminate nucleotides that are not A, T, C, or G or if all nucleotides are the same - nucs = [ - x - for x in nucs - if x.upper() in ["A", "T", "C", "G", "N"] or len(set(nucs)) == 1 - ] - consensus_root += max(set(nucs), key=nucs.count) + nucs = [seq.seq[i] for seq in root_seqs] + candidates = [x for x in nucs if x.upper() in ["A", "T", "C", "G"]] + if not candidates: + candidates = nucs + consensus_root += max(sorted(set(candidates)), key=candidates.count) return consensus_root diff --git a/barcodeforge/utils.py b/barcodeforge/utils.py index 3cf7c0a..d991ccc 100644 --- a/barcodeforge/utils.py +++ b/barcodeforge/utils.py @@ -1,4 +1,5 @@ import os +import re import subprocess import rich_click as click from rich.console import Console @@ -101,7 +102,15 @@ def ensure_reference_is_first_in_alignment( click.Abort: If the alignment is empty, or the reference genome length does not match the aligned sequence length. """ - ref = SeqIO.read(reference_genome_path, "fasta") + try: + ref = SeqIO.read(reference_genome_path, "fasta") + except ValueError as e: + # SeqIO.read raises ValueError if the file has zero or more than one record. + print_error( + f"Reference genome '{reference_genome_path}' must contain exactly one " + f"sequence ({e})." + ) + raise click.Abort() records = list(SeqIO.parse(alignment_path, "fasta")) if not records: @@ -177,6 +186,11 @@ def run_subprocess_command( f"{error_message_prefix}: {cmd[0]} command not found. Please ensure it is installed and in your PATH." ) raise click.Abort() + except PermissionError: + print_error( + f"{error_message_prefix}: {cmd[0]} is not executable. Please check its file permissions." + ) + raise click.Abort() except subprocess.CalledProcessError as e: print_error(f"{error_message_prefix} {cmd[0]}: {e}") if debug: @@ -199,5 +213,7 @@ def sortFun(x: str) -> int: Returns: int: The numeric position extracted from the mutation string. """ - # sort based on nuc position, ignoring nuc identities - return int(x[1 : (len(x) - 1)]) + match = re.search(r"\d+", x) + if match is None: + raise ValueError(f"Cannot extract a numeric position from mutation '{x}'") + return int(match.group()) diff --git a/pyproject.toml b/pyproject.toml index 0f83998..9fa0923 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,12 @@ [project] name = "BarcodeForge" -version = "1.1.5" +version = "1.2.1" description = "A CLI tool for generating pathogen-specific barcodes for Freyja." readme = "README.md" requires-python = ">=3.10" dependencies = [ "dendropy>=4.5.2", "ete4>=4.3.0", - "pathlib>=1.0.1", "rich-click>=1.6.0", "rich>=10.0.0", "six>=1.17.0", diff --git a/scripts/check_version_consistency.py b/scripts/check_version_consistency.py new file mode 100644 index 0000000..4a34a59 --- /dev/null +++ b/scripts/check_version_consistency.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Check that the package version is identical across every file that hardcodes it. + +Sources checked: + - pyproject.toml (``[project]`` -> ``version = "..."``) + - barcodeforge/__init__.py (``__version__ = "..."``) + +The script prints every version it finds and exits: + - 0 if all sources agree, + - 1 if they disagree or a version string cannot be found in a source. + +It is dependency-free (regex based, standard library only) so it runs on any +Python >= 3.10 without installing the package or any third-party parser. To add +another source, append an entry to ``SOURCES`` below. + +Run locally with: python scripts/check_version_consistency.py +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _extract_pyproject_version(text: str) -> str | None: + """Return the ``version`` declared inside the ``[project]`` table.""" + # Isolate the [project] table so we never match a `version` from another + # table (e.g. a future [tool.*] section). + section = re.search(r"(?ms)^\[project\][^\[]*", text) + if section is None: + return None + match = re.search(r"""(?m)^\s*version\s*=\s*["']([^"']+)["']""", section.group(0)) + return match.group(1) if match else None + + +def _extract_dunder_version(text: str) -> str | None: + """Return the value of a ``__version__ = "..."`` assignment.""" + match = re.search(r"""(?m)^\s*__version__\s*=\s*["']([^"']+)["']""", text) + return match.group(1) if match else None + + +# (label, path, extractor) — extend this list to check additional files. +SOURCES = [ + ("pyproject.toml", REPO_ROOT / "pyproject.toml", _extract_pyproject_version), + ( + "barcodeforge/__init__.py", + REPO_ROOT / "barcodeforge" / "__init__.py", + _extract_dunder_version, + ), +] + + +def main() -> int: + versions: dict[str, str] = {} + errors: list[str] = [] + + for label, path, extractor in SOURCES: + if not path.exists(): + errors.append(f"{label}: file not found ({path})") + continue + version = extractor(path.read_text(encoding="utf-8")) + if version is None: + errors.append(f"{label}: no version string found") + continue + versions[label] = version + print(f" {label}: {version}") + + if errors: + print("\nERROR: could not read a version from every source:", file=sys.stderr) + for err in errors: + print(f" - {err}", file=sys.stderr) + return 1 + + unique_versions = set(versions.values()) + if len(unique_versions) != 1: + print("\nERROR: version mismatch across files:", file=sys.stderr) + for label, version in versions.items(): + print(f" - {label}: {version}", file=sys.stderr) + return 1 + + print(f"\nOK: all sources report version {unique_versions.pop()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ref_muts.py b/tests/test_ref_muts.py index 60a3102..5889aef 100644 --- a/tests/test_ref_muts.py +++ b/tests/test_ref_muts.py @@ -106,7 +106,7 @@ def test_reverse_mutations_to_root(): expected = {1: {"base": "G", "mut": "A"}, 2: {"base": "T", "mut": "C"}} assert reversed_muts == expected - assert _reverse_mutations_to_root(OrderedDict()) == {0: {"base": "", "mut": ""}} + assert _reverse_mutations_to_root(OrderedDict()) == {} def test_construct_root_sequence():