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
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ dev = [

[tool.ruff]
line-length = 127

[tool.ruff.lint]
select = ["E", "F", "W"]
extend-select = ["W292"]
45 changes: 24 additions & 21 deletions src/hammingdistancecalculator/hamming_distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,16 @@ def hamming_distance(seq1: str, seq2: str) -> int:
check_1 = is_valid_dna(seq1)
check_2 = is_valid_dna(seq2)
if not check_1 and not check_2:
raise ValueError(f'Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: {seq1}, {seq2}')
raise ValueError(
f'Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: {seq1}, {seq2}')
elif not check_1 and check_2:
raise ValueError(f'Provided sequence for sequence 1 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: {seq1}')
raise ValueError(
f'Provided sequence for sequence 1 is invalid, only A, C, T, and G nucleotides are allowed. '
f'Invalid sequence: {seq1}')
elif check_1 and not check_2:
raise ValueError(f'Provided sequence for sequence 2 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: {seq2}')
raise ValueError(
f'Provided sequence for sequence 2 is invalid, only A, C, T, and G nucleotides are allowed. '
f'Invalid sequence: {seq2}')

# break if index have unequal length
if len(seq1) != len(seq2):
Expand All @@ -56,7 +61,6 @@ def hamming_distance(seq1: str, seq2: str) -> int:

def is_valid_dna(seq: str) -> bool:
"""Check if provided dna string contains valid characters

Currently only 'A', 'C', 'T' and 'G' are checked for.

Args:
Expand All @@ -71,7 +75,6 @@ def is_valid_dna(seq: str) -> bool:

def rev_comp(dna: str) -> str:
"""Function which returns the reverse complement of a DNA string

Args:
dna: The input DNA string we want to convert

Expand All @@ -88,7 +91,6 @@ def rev_comp(dna: str) -> str:

def is_valid_input_csv(csv_file: Path) -> bool:
"""Function that tests input csv file given to be in the expected format.

We expect the file to have a header with labels 'label', 'barcode'.
We expect no empty labels
We expect no empty barcodes
Expand Down Expand Up @@ -209,19 +211,18 @@ def compare_sample_barcode_list(sample_barcode_list: list) -> dict:
# main script
@cli.command()
def main(input_csv: Path = typer.Argument(help="Pad naar input CSV met kolommen: label,barcode"),
max_distance: int = typer.Option(
1,
"--max-distance",
"-d",
help="Maximale hamming distance waarvoor output wordt geschreven (default op 1, wat 0 & 1 schrijft)."
),
outpath: Path = typer.Option(
".",
"--outpath",
"-o",
help="Directory waar output files worden geschreven (default huidige directory)."
)):

max_distance: int = typer.Option(
1,
"--max-distance",
"-d",
help="Maximale hamming distance waarvoor output wordt geschreven (default op 1, wat 0 & 1 schrijft)."
),
outpath: Path = typer.Option(
".",
"--outpath",
"-o",
help="Directory waar output files worden geschreven (default huidige directory)."
)):
# read input
sample_barcode_list = load_barcodes(input_csv)

Expand Down Expand Up @@ -251,10 +252,12 @@ def main(input_csv: Path = typer.Argument(help="Pad naar input CSV met kolommen:
# write each file
with output_path.open('w', newline='') as file_handle:
writer = csv.writer(file_handle)
writer.writerow([f"Number of comparisons found with hamming distance {counter}: {len(hamming_distance_dict[counter])}"])
writer.writerow(
[f"Number of comparisons found with hamming distance {counter}: {len(hamming_distance_dict[counter])}"])

for item in hamming_distance_dict[counter]:
writer.writerow([f"Barcode {item.split('_vs_')[0]} vs barcode {item.split('_vs_')[1]} has hamming distance {counter}"])
writer.writerow(
[f"Barcode {item.split('_vs_')[0]} vs barcode {item.split('_vs_')[1]} has hamming distance {counter}"])


if __name__ == "__main__":
Expand Down
104 changes: 64 additions & 40 deletions tests/test_hamming_distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,57 +11,78 @@
)
from typer.testing import CliRunner


# test hamming distance function
@pytest.mark.parametrize(
"a,b,expected",
[
("A", "A", 0), # distance of 0, identical letters
("a", "a", 0), # distance of 0, lowe case
("a", "A", 0), # distance of 0, mixed case
("C", "T", 1), # distance of 1, 1 letter difference
("c", "t", 1), # distance of 1, lower case
("C", "t", 1), # distance of 1, mixed case
("ACTGCG", "ACTGCG", 0), # distance of 0, multiple letters
("actgcg", "actgcg", 0), # distance of 0, lower case
("ACTGCG", "actgcg", 0), # distance of 0, mixed case
("AACCGGTT", "TTGGCCAA", 8), # distance of 6, full mismatch
("aaccttgg", "ttggccaa", 8), # distance of 6, full mismatch
("AACCGGTT", "ttggccaa", 8), # distance of 6, full mismatch
("A", "A", 0), # distance of 0, identical letters
("a", "a", 0), # distance of 0, lowe case
("a", "A", 0), # distance of 0, mixed case
("C", "T", 1), # distance of 1, 1 letter difference
("c", "t", 1), # distance of 1, lower case
("C", "t", 1), # distance of 1, mixed case
("ACTGCG", "ACTGCG", 0), # distance of 0, multiple letters
("actgcg", "actgcg", 0), # distance of 0, lower case
("ACTGCG", "actgcg", 0), # distance of 0, mixed case
("AACCGGTT", "TTGGCCAA", 8), # distance of 6, full mismatch
("aaccttgg", "ttggccaa", 8), # distance of 6, full mismatch
("AACCGGTT", "ttggccaa", 8), # distance of 6, full mismatch
],
)
def test_hamming_distance(a, b, expected):
# test normal cases
assert hamming_distance(a, b) == expected


@pytest.mark.parametrize(
"a,b,expected",
[
("", "", 0), # empty strings
("A", "", 0), # unequal length
("AC", "A", 0), # unequal length
("A", "AC", 0), # unequal length
("", "", 0), # empty strings
("A", "", 0), # unequal length
("AC", "A", 0), # unequal length
("A", "AC", 0), # unequal length
],
)
def test_hamming_distance_empty(a, b, expected):
# test empty strings
with pytest.raises(ValueError, match=r"^Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences:"):
with pytest.raises(ValueError, match=r"^Provided sequences are invalid, "
r"only A, C, T, and G nucleotides are allowed. Invalid sequences:"):
hamming_distance("", "")


# test invalid dna provided
@pytest.mark.parametrize(
"a,b,error_message",
[
("QQ", "AA", "Provided sequence for sequence 1 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: QQ"), # Left side invalid
("AA", "BB", "Provided sequence for sequence 2 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: BB"), # Right side invalid
("QA", "AA", "Provided sequence for sequence 1 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: QA"), # Left side partially invalid
("AQ", "AA", "Provided sequence for sequence 1 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: AQ"), # Left side partially invalid
("AA", "QA", "Provided sequence for sequence 2 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: QA"), # Right side partially invalid
("AA", "AQ", "Provided sequence for sequence 2 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: AQ"), # Right side partially invalid
("QQ", "VV", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: QQ, VV"), # Both sides invalid
("QA", "VA", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: QA, VA"), # Both sides invalid
("QA", "AV", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: QA, AV"), # Both sides invalid
("AQ", "VA", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: AQ, VA"), # Both sides invalid
("AQ", "AV", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: AQ, AV"), # Both sides invalid
("QQ", "AA",
"Provided sequence for sequence 1 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: QQ"),
# Left side invalid
("AA", "BB",
"Provided sequence for sequence 2 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: BB"),
# Right side invalid
("QA", "AA",
"Provided sequence for sequence 1 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: QA"),
# Left side partially invalid
("AQ", "AA",
"Provided sequence for sequence 1 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: AQ"),
# Left side partially invalid
("AA", "QA",
"Provided sequence for sequence 2 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: QA"),
# Right side partially invalid
("AA", "AQ",
"Provided sequence for sequence 2 is invalid, only A, C, T, and G nucleotides are allowed. Invalid sequence: AQ"),
# Right side partially invalid
("QQ", "VV", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: QQ, VV"),
# Both sides invalid
("QA", "VA", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: QA, VA"),
# Both sides invalid
("QA", "AV", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: QA, AV"),
# Both sides invalid
("AQ", "VA", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: AQ, VA"),
# Both sides invalid
("AQ", "AV", "Provided sequences are invalid, only A, C, T, and G nucleotides are allowed. Invalid sequences: AQ, AV"),
# Both sides invalid
],
)
def test_hamming_distance_invalid_dna(a, b, error_message):
Expand All @@ -75,20 +96,21 @@ def test_hamming_distance_invalid_dna(a, b, error_message):
@pytest.mark.parametrize(
"seq,expected",
[
("A", True), # Single valid character
("ACTG", True), # All valid characters
("AAA", True), # Repeated valid characters
("", False), # Empty string should be invalid
("ACTGN", False), # Contains 'N' which is not allowed
("XYZ", False), # Completely invalid characters
("actg", False), # Lowercase letters should fail (regex expects uppercase)
("ACTG123", False), # Numbers are not allowed
("ACTG!", False), # Special characters are not allowed
("A", True), # Single valid character
("ACTG", True), # All valid characters
("AAA", True), # Repeated valid characters
("", False), # Empty string should be invalid
("ACTGN", False), # Contains 'N' which is not allowed
("XYZ", False), # Completely invalid characters
("actg", False), # Lowercase letters should fail (regex expects uppercase)
("ACTG123", False), # Numbers are not allowed
("ACTG!", False), # Special characters are not allowed
]
)
def test_is_valid_dna(seq, expected):
assert is_valid_dna(seq) == expected


# test rev_comp
def test_rev_comp():
# default
Expand All @@ -97,14 +119,16 @@ def test_rev_comp():
# palindrome
assert rev_comp("ATAT") == "ATAT"


# test is_valid_input_csv
def test_is_valid_input_csv():
bad_file = 'tests/bad_input.csv'
good_file = 'tests/good_input.csv'

assert is_valid_input_csv(good_file)

with pytest.raises(ValueError, match=re.escape("Line ['S1', ' ACTG', ' invalid extra'] has too many elements, 2 expected.")):
with pytest.raises(ValueError,
match=re.escape("Line ['S1', ' ACTG', ' invalid extra'] has too many elements, 2 expected.")):
is_valid_input_csv(bad_file)


Expand All @@ -117,7 +141,7 @@ def test_load_barcodes_correct(tmp_path):
encoding="utf-8")

test_barcodes = load_barcodes(test_file)
assert test_barcodes == [("S1", "ACTG"),("S2", "TTTT")]
assert test_barcodes == [("S1", "ACTG"), ("S2", "TTTT")]


def test_load_barcodes_strips_whitespace(tmp_path):
Expand Down Expand Up @@ -318,4 +342,4 @@ def test_cli_missing_required_argument_fails():
result = runner.invoke(cli, []) # Geen input argument

assert result.exit_code != 0
assert "Missing argument" in result.output or "Usage:" in result.output
assert "Missing argument" in result.output or "Usage:" in result.output
Loading