Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,16 @@ jobs:
pip install black
- name: Lint with Black
run: |
black --check .
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
6 changes: 0 additions & 6 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion barcodeforge/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.1.5"
__version__ = "1.2.1"
10 changes: 9 additions & 1 deletion barcodeforge/auspice_tree_to_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion barcodeforge/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import shlex
import rich_click as click
from .format_tree import convert_nexus_to_newick
from .utils import (
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion barcodeforge/format_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:]
Expand Down
3 changes: 1 addition & 2 deletions barcodeforge/generate_barcodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(">")
Expand Down
6 changes: 5 additions & 1 deletion barcodeforge/plot_barcode.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"])
Expand Down
36 changes: 20 additions & 16 deletions barcodeforge/ref_muts.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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


Expand Down
22 changes: 19 additions & 3 deletions barcodeforge/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import re
import subprocess
import rich_click as click
from rich.console import Console
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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())
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
89 changes: 89 additions & 0 deletions scripts/check_version_consistency.py
Original file line number Diff line number Diff line change
@@ -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())
2 changes: 1 addition & 1 deletion tests/test_ref_muts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading