diff --git a/prymer/primer3/primer3.py b/prymer/primer3/primer3.py index 031c9f2..a642aae 100644 --- a/prymer/primer3/primer3.py +++ b/prymer/primer3/primer3.py @@ -48,10 +48,10 @@ parameters and target region. ```python ->>> from prymer.primer3.primer3_parameters import Primer3Parameters +>>> from prymer.primer3.primer3_parameters import PrimerAndAmpliconParameters >>> from prymer.api import MinOptMax >>> target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) ->>> params = Primer3Parameters( \ +>>> params = PrimerAndAmpliconParameters( \ amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ primer_sizes=MinOptMax(min=29, max=31, opt=30), \ @@ -60,7 +60,7 @@ ) >>> design_input = Primer3Input( \ target=target, \ - params=params, \ + primer_and_amplicon_params=params, \ task=DesignLeftPrimersTask(), \ ) >>> left_result = designer.design_primers(design_input=design_input) @@ -312,7 +312,7 @@ def get_design_sequences(self, region: Span) -> tuple[str, str]: def _is_valid_primer(design_input: Primer3Input, primer_design: Primer) -> bool: return ( primer_design.longest_dinucleotide_run_length() - <= design_input.params.primer_max_dinuc_bases + <= design_input.primer_and_amplicon_params.primer_max_dinuc_bases ) @staticmethod @@ -335,13 +335,13 @@ def _screen_pair_results( valid: bool = True if ( primer_pair.left_primer.longest_dinucleotide_run_length() - > design_input.params.primer_max_dinuc_bases + > design_input.primer_and_amplicon_params.primer_max_dinuc_bases ): # if the left primer has too many dinucleotide bases, fail it dinuc_pair_failures.append(primer_pair.left_primer) valid = False if ( primer_pair.right_primer.longest_dinucleotide_run_length() - > design_input.params.primer_max_dinuc_bases + > design_input.primer_and_amplicon_params.primer_max_dinuc_bases ): # if the right primer has too many dinucleotide bases, fail it dinuc_pair_failures.append(primer_pair.right_primer) valid = False @@ -374,7 +374,7 @@ def design_primers(self, design_input: Primer3Input) -> Primer3Result: # noqa: region: Span = self._pad_target_region( target=design_input.target, - max_amplicon_length=design_input.params.max_amplicon_length, + max_amplicon_length=design_input.primer_and_amplicon_params.max_amplicon_length, ) soft_masked, hard_masked = self.get_design_sequences(region) diff --git a/prymer/primer3/primer3_parameters.py b/prymer/primer3/primer3_parameters.py index e45f731..5cbf988 100644 --- a/prymer/primer3/primer3_parameters.py +++ b/prymer/primer3/primer3_parameters.py @@ -1,26 +1,27 @@ """ -# Primer3Parameters Class and Methods +# PrimerAndAmpliconParameters Class and Methods -The [`Primer3Parameters`][prymer.primer3.primer3_parameters.Primer3Parameters] class stores -user input and maps it to the correct Primer3 fields. +The [`PrimerAndAmpliconParameters`][prymer.primer3.primer3_parameters.PrimerAndAmpliconParameters] +class stores user input and maps it to the correct Primer3 fields. Primer3 considers many criteria for primer design, including characteristics of candidate primers and the resultant amplicon product, as well as potential complications (off-target priming, primer dimer formation). Users can specify many of these constraints in Primer3, some of which are used to quantify a "score" for each primer design. -The Primer3Parameters class stores commonly used constraints for primer design: GC content, melting -temperature, and size of both primers and expected amplicon. Additional criteria include the maximum -homopolymer length, ambiguous bases, and bases in a dinucleotide run within a primer. By default, -primer design avoids masked bases, returns 5 primers, and sets the GC clamp to be no larger than 5. +The PrimerAndAmpliconParameters class stores commonly used constraints for primer design: +GC content, melting temperature, and size of both primers and expected amplicon. +Additional criteria include the maximum homopolymer length, ambiguous bases, and bases in a +dinucleotide run within a primer. By default, primer design avoids masked bases, returns 5 primers, +and sets the GC clamp to be no larger than 5. -The `to_input_tags()` method in `Primer3Parameters` converts these parameters into tag-values pairs -for use when executing `Primer3`. +The `to_input_tags()` method in `PrimerAndAmpliconParameters` converts these parameters into +tag-values pairs for use when executing `Primer3`. ## Examples ```python ->>> params = Primer3Parameters( \ +>>> params = PrimerAndAmpliconParameters( \ amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ primer_sizes=MinOptMax(min=29, max=31, opt=30), \ @@ -53,16 +54,18 @@ ``` """ +import warnings from dataclasses import dataclass from typing import Any +from typing import Optional from prymer.api.minoptmax import MinOptMax from prymer.primer3.primer3_input_tag import Primer3InputTag @dataclass(frozen=True, init=True, slots=True) -class Primer3Parameters: - """Holds common primer design options that Primer3 uses to inform primer design. +class PrimerAndAmpliconParameters: + """Holds common primer and amplicon design options that Primer3 uses to inform primer design. Attributes: amplicon_sizes: the min, optimal, and max amplicon size @@ -140,3 +143,105 @@ def max_amplicon_length(self) -> int: def max_primer_length(self) -> int: """Max primer length""" return int(self.primer_sizes.max) + + +@dataclass(frozen=True, init=True, slots=True) +class Primer3Parameters(PrimerAndAmpliconParameters): + """A deprecated alias for `PrimerAndAmpliconParameters` intended to maintain backwards + compatibility with earlier releases of `prymer`.""" + + warnings.warn( + "The Primer3Parameters class was deprecated, use PrimerAndAmpliconParameters instead", + DeprecationWarning, + ) + + +@dataclass(frozen=True, init=True, slots=True) +class ProbeParameters: + """Holds common primer design options that Primer3 uses to inform internal probe design. + + Attributes: + probe_sizes: the min, optimal, and max probe size + probe_tms: the min, optimal, and max probe melting temperatures + probe_gcs: the min and maximal GC content for individual probes + probe_excluded_regions: excluded regions (start, length) that probes must not overlap + probe_max_polyX: the max homopolymer length acceptable within a probe + probe_max_Ns: the max number of ambiguous bases acceptable within a probe + probe_max_self_any: max allowable local alignment score when evaluating an individual probe + for self-complementarity throughout the probe sequence + probe_max_self_any_thermo: max allowable score for self-complementarity of the probe + sequence using a thermodynamic approach + probe_max_self_end: max allowable 3'-anchored global alignment score when testing a single + probe for self-complementarity + probe_max_self_end_thermo: similar to `probe_max_end_any` but uses a thermodynamic approach + to evaluate a probe for self-complementarity + probe_max_hairpin_thermo: most stable monomer structure as calculated by a thermodynamic + approach + + Defaults in this class are set as recommended by the Primer3 manual. + Please see the Primer3 manual for additional details: https://primer3.org/manual.html#globalTags + + Note that the Primer3 documentation advises that, while `probe_max_end_any` is meaningless + when applied to internal oligos used for hybridization-based detection, + `PRIMER_INTERNAL_MAX_SELF_END` should be set at least as high as `PRIMER_INTERNAL_MAX_SELF_ANY`. + Therefore, both parameters are exposed here. + + """ + + probe_sizes: MinOptMax[int] + probe_tms: MinOptMax[float] + probe_gcs: MinOptMax[float] + probe_excluded_regions: Optional[list[tuple[int, int]]] = None + probe_max_polyX: int = 5 + probe_max_Ns: int = 0 + probe_max_self_any: float = 12.0 + probe_max_self_any_thermo: float = 47.0 + probe_max_self_end: float = 12.0 + probe_max_self_end_thermo: float = 47.0 + probe_max_hairpin_thermo: float = 47.0 + + def __post_init__(self) -> None: + if not isinstance(self.probe_sizes.min, int): + raise TypeError("Probe sizes must be integers") + if not isinstance(self.probe_tms.min, float) or not isinstance(self.probe_gcs.min, float): + raise TypeError("Probe melting temperatures and GC content must be floats") + if self.probe_excluded_regions is not None: + # if probe_excluded regions are provided, ensure they are each a tuple[int, int] + if not all( + isinstance(region, tuple) for region in self.probe_excluded_regions + ) or not all( + isinstance(constraint, int) + for region in self.probe_excluded_regions + for constraint in region + ): + raise TypeError( + "Excluded regions for probe design must be list[tuple[int, int]]" + " to enumerate the start and length of each excluded region" + ) + + def to_input_tags(self) -> dict[Primer3InputTag, Any]: + """Converts input params to Primer3InputTag to feed directly into Primer3.""" + mapped_dict = { + Primer3InputTag.PRIMER_INTERNAL_MIN_SIZE: self.probe_sizes.min, + Primer3InputTag.PRIMER_INTERNAL_OPT_SIZE: self.probe_sizes.opt, + Primer3InputTag.PRIMER_INTERNAL_MAX_SIZE: self.probe_sizes.max, + Primer3InputTag.PRIMER_INTERNAL_MIN_TM: self.probe_tms.min, + Primer3InputTag.PRIMER_INTERNAL_OPT_TM: self.probe_tms.opt, + Primer3InputTag.PRIMER_INTERNAL_MAX_TM: self.probe_tms.max, + Primer3InputTag.PRIMER_INTERNAL_MIN_GC: self.probe_gcs.min, + Primer3InputTag.PRIMER_INTERNAL_OPT_GC_PERCENT: self.probe_gcs.opt, + Primer3InputTag.PRIMER_INTERNAL_MAX_GC: self.probe_gcs.max, + Primer3InputTag.PRIMER_INTERNAL_MAX_POLY_X: self.probe_max_polyX, + Primer3InputTag.PRIMER_INTERNAL_MAX_NS_ACCEPTED: self.probe_max_Ns, + Primer3InputTag.PRIMER_INTERNAL_MAX_SELF_ANY: self.probe_max_self_any, + Primer3InputTag.PRIMER_INTERNAL_MAX_SELF_ANY_TH: self.probe_max_self_any_thermo, + Primer3InputTag.PRIMER_INTERNAL_MAX_SELF_END: self.probe_max_self_end, + Primer3InputTag.PRIMER_INTERNAL_MAX_SELF_END_TH: self.probe_max_self_end_thermo, + Primer3InputTag.PRIMER_INTERNAL_MAX_HAIRPIN_TH: self.probe_max_hairpin_thermo, + } + + if self.probe_excluded_regions is not None: + mapped_dict[Primer3InputTag.SEQUENCE_INTERNAL_EXCLUDED_REGION] = ( + self.probe_excluded_regions + ) + return mapped_dict diff --git a/tests/primer3/test_primer3.py b/tests/primer3/test_primer3.py index 2409dfe..56b5883 100644 --- a/tests/primer3/test_primer3.py +++ b/tests/primer3/test_primer3.py @@ -16,7 +16,7 @@ from prymer.primer3.primer3 import Primer3Failure from prymer.primer3.primer3 import Primer3Result from prymer.primer3.primer3_input import Primer3Input -from prymer.primer3.primer3_parameters import Primer3Parameters +from prymer.primer3.primer3_parameters import PrimerAndAmpliconParameters from prymer.primer3.primer3_task import DesignLeftPrimersTask from prymer.primer3.primer3_task import DesignPrimerPairsTask from prymer.primer3.primer3_task import DesignRightPrimersTask @@ -33,8 +33,8 @@ def vcf_path() -> Path: @pytest.fixture -def single_primer_params() -> Primer3Parameters: - return Primer3Parameters( +def single_primer_params() -> PrimerAndAmpliconParameters: + return PrimerAndAmpliconParameters( amplicon_sizes=MinOptMax(min=100, max=250, opt=200), amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), primer_sizes=MinOptMax(min=29, max=31, opt=30), @@ -46,8 +46,8 @@ def single_primer_params() -> Primer3Parameters: @pytest.fixture -def pair_primer_params() -> Primer3Parameters: - return Primer3Parameters( +def pair_primer_params() -> PrimerAndAmpliconParameters: + return PrimerAndAmpliconParameters( amplicon_sizes=MinOptMax(min=100, max=200, opt=150), amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=72.5), primer_sizes=MinOptMax(min=20, max=30, opt=25), @@ -59,8 +59,8 @@ def pair_primer_params() -> Primer3Parameters: @pytest.fixture -def design_fail_gen_primer3_params() -> Primer3Parameters: - return Primer3Parameters( +def design_fail_gen_primer3_params() -> PrimerAndAmpliconParameters: + return PrimerAndAmpliconParameters( amplicon_sizes=MinOptMax(min=200, max=300, opt=250), amplicon_tms=MinOptMax(min=65.0, max=75.0, opt=74.0), primer_sizes=MinOptMax(min=24, max=27, opt=26), @@ -132,7 +132,7 @@ def valid_primer_pairs( def test_design_primers_raises( genome_ref: Path, - single_primer_params: Primer3Parameters, + single_primer_params: PrimerAndAmpliconParameters, ) -> None: """Test that design_primers() raises when given an invalid argument.""" @@ -143,7 +143,7 @@ def test_design_primers_raises( number_primers_return="invalid", # type: ignore ) invalid_design_input = Primer3Input( - target=target, params=illegal_primer3_params, task=DesignLeftPrimersTask() + target=target, primer_and_amplicon_params=illegal_primer3_params, task=DesignLeftPrimersTask() ) with pytest.raises(ValueError, match="Primer3 failed"): Primer3(genome_fasta=genome_ref).design_primers(design_input=invalid_design_input) @@ -152,13 +152,13 @@ def test_design_primers_raises( def test_left_primer_valid_designs( genome_ref: Path, - single_primer_params: Primer3Parameters, + single_primer_params: PrimerAndAmpliconParameters, ) -> None: """Test that left primer designs are within the specified design specifications.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) design_input = Primer3Input( target=target, - params=single_primer_params, + primer_and_amplicon_params=single_primer_params, task=DesignLeftPrimersTask(), ) @@ -200,13 +200,13 @@ def test_left_primer_valid_designs( def test_right_primer_valid_designs( genome_ref: Path, - single_primer_params: Primer3Parameters, + single_primer_params: PrimerAndAmpliconParameters, ) -> None: """Test that right primer designs are within the specified design specifications.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) design_input = Primer3Input( target=target, - params=single_primer_params, + primer_and_amplicon_params=single_primer_params, task=DesignRightPrimersTask(), ) with Primer3(genome_fasta=genome_ref) as designer: @@ -247,13 +247,13 @@ def test_right_primer_valid_designs( assert designer.is_alive -def test_primer_pair_design(genome_ref: Path, pair_primer_params: Primer3Parameters) -> None: +def test_primer_pair_design(genome_ref: Path, pair_primer_params: PrimerAndAmpliconParameters) -> None: """Test that paired primer design produces left and right primers within design constraints. Additionally, assert that `PrimerPair.amplicon_sequence()` matches reference sequence.""" target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) design_input = Primer3Input( target=target, - params=pair_primer_params, + primer_and_amplicon_params=pair_primer_params, task=DesignPrimerPairsTask(), ) with Primer3(genome_fasta=genome_ref) as designer: @@ -328,7 +328,7 @@ def test_primer_pair_design(genome_ref: Path, pair_primer_params: Primer3Paramet assert pair_design.right_primer.bases.upper() == right_from_ref.upper() -def test_fasta_close_valid(genome_ref: Path, single_primer_params: Primer3Parameters) -> None: +def test_fasta_close_valid(genome_ref: Path, single_primer_params: PrimerAndAmpliconParameters) -> None: """Test that fasta file is closed when underlying subprocess is terminated.""" designer = Primer3(genome_fasta=genome_ref) assert designer._fasta.is_open() @@ -338,7 +338,7 @@ def test_fasta_close_valid(genome_ref: Path, single_primer_params: Primer3Parame target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) design_input = Primer3Input( target=target, - params=single_primer_params, + primer_and_amplicon_params=single_primer_params, task=DesignLeftPrimersTask(), ) @@ -396,7 +396,7 @@ def test_variant_lookup( def test_screen_pair_results( - valid_primer_pairs: list[PrimerPair], genome_ref: Path, pair_primer_params: Primer3Parameters + valid_primer_pairs: list[PrimerPair], genome_ref: Path, pair_primer_params: PrimerAndAmpliconParameters ) -> None: """Test that `_is_valid_primer()` and `_screen_pair_results()` use `Primer3Parameters.primer_max_dinuc_bases` to disqualify primers when applicable. @@ -407,14 +407,14 @@ def test_screen_pair_results( target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) design_input = Primer3Input( target=target, - params=pair_primer_params, + primer_and_amplicon_params=pair_primer_params, task=DesignPrimerPairsTask(), ) lower_dinuc_thresh = replace(pair_primer_params, primer_max_dinuc_bases=2) # lower from 6 to 2 altered_design_input = Primer3Input( target=target, - params=lower_dinuc_thresh, + primer_and_amplicon_params=lower_dinuc_thresh, task=DesignPrimerPairsTask(), ) with Primer3(genome_fasta=genome_ref) as designer: @@ -426,11 +426,11 @@ def test_screen_pair_results( for primer_pair in base_primer_pair_designs: assert ( primer_pair.left_primer.longest_dinucleotide_run_length() - <= design_input.params.primer_max_dinuc_bases + <= design_input.primer_and_amplicon_params.primer_max_dinuc_bases ) assert ( primer_pair.right_primer.longest_dinucleotide_run_length() - <= design_input.params.primer_max_dinuc_bases + <= design_input.primer_and_amplicon_params.primer_max_dinuc_bases ) assert Primer3._is_valid_primer( design_input=design_input, primer_design=primer_pair.left_primer @@ -446,14 +446,14 @@ def test_screen_pair_results( ) assert [ design.longest_dinucleotide_run_length() - > altered_design_input.params.primer_max_dinuc_bases + > altered_design_input.primer_and_amplicon_params.primer_max_dinuc_bases for design in altered_dinuc_failures ] assert len(altered_designs) == 0 def test_build_failures( - valid_primer_pairs: list[PrimerPair], genome_ref: Path, pair_primer_params: Primer3Parameters + valid_primer_pairs: list[PrimerPair], genome_ref: Path, pair_primer_params: PrimerAndAmpliconParameters ) -> None: """Test that `build_failures()` parses Primer3 `failure_strings` correctly and includes failures related to long dinucleotide runs.""" @@ -462,7 +462,7 @@ def test_build_failures( low_dinuc_thresh = replace(pair_primer_params, primer_max_dinuc_bases=2) # lower from 6 to 2 altered_design_input = Primer3Input( target=target, - params=low_dinuc_thresh, + primer_and_amplicon_params=low_dinuc_thresh, task=DesignPrimerPairsTask(), ) designer = Primer3(genome_fasta=genome_ref) @@ -492,7 +492,7 @@ def test_build_failures( def test_build_failures_debugs( valid_primer_pairs: list[PrimerPair], genome_ref: Path, - pair_primer_params: Primer3Parameters, + pair_primer_params: PrimerAndAmpliconParameters, caplog: pytest.LogCaptureFixture, ) -> None: """Test that we log a debug message in the event of an unknown Primer3Failure reason.""" @@ -501,7 +501,7 @@ def test_build_failures_debugs( design_input = Primer3Input( target=target, - params=pair_primer_params, + primer_and_amplicon_params=pair_primer_params, task=DesignPrimerPairsTask(), ) designer = Primer3(genome_fasta=genome_ref) diff --git a/tests/primer3/test_primer3_parameters.py b/tests/primer3/test_primer3_parameters.py index d2f70a3..a6de183 100644 --- a/tests/primer3/test_primer3_parameters.py +++ b/tests/primer3/test_primer3_parameters.py @@ -4,12 +4,13 @@ from prymer.api.minoptmax import MinOptMax from prymer.primer3.primer3_input_tag import Primer3InputTag -from prymer.primer3.primer3_parameters import Primer3Parameters +from prymer.primer3.primer3_parameters import PrimerAndAmpliconParameters +from prymer.primer3.primer3_parameters import ProbeParameters @pytest.fixture -def valid_primer3_params() -> Primer3Parameters: - return Primer3Parameters( +def valid_primer_amplicon_params() -> PrimerAndAmpliconParameters: + return PrimerAndAmpliconParameters( amplicon_sizes=MinOptMax(min=200, opt=250, max=300), amplicon_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), primer_sizes=MinOptMax(min=18, opt=21, max=27), @@ -18,33 +19,120 @@ def valid_primer3_params() -> Primer3Parameters: ) -def test_primer3_param_construction_valid(valid_primer3_params: Primer3Parameters) -> None: - """Test Primer3Parameters class instantiation with valid input""" - assert valid_primer3_params.amplicon_sizes.min == 200 - assert valid_primer3_params.amplicon_sizes.opt == 250 - assert valid_primer3_params.amplicon_sizes.max == 300 - assert valid_primer3_params.primer_gcs.min == 45.0 - assert valid_primer3_params.primer_gcs.opt == 55.0 - assert valid_primer3_params.primer_gcs.max == 60.0 +@pytest.fixture +def valid_probe_params() -> ProbeParameters: + return ProbeParameters( + probe_sizes=MinOptMax(min=18, opt=22, max=30), + probe_tms=MinOptMax(min=65.0, opt=70.0, max=75.0), + probe_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), + ) -def test_primer3_param_construction_raises(valid_primer3_params: Primer3Parameters) -> None: - """Test that Primer3Parameters post_init raises with invalid input.""" +@pytest.fixture +def valid_probe_params_with_exclude_regions() -> ProbeParameters: + return ProbeParameters( + probe_sizes=MinOptMax(min=18, opt=22, max=30), + probe_tms=MinOptMax(min=65.0, opt=70.0, max=75.0), + probe_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), + probe_excluded_regions=[(1, 10), (20, 10), (30, 10)], + ) + + +def test_primer_amplicon_param_construction_valid( + valid_primer_amplicon_params: PrimerAndAmpliconParameters, +) -> None: + """Test PrimerAndAmpliconParameters class instantiation with valid input""" + assert valid_primer_amplicon_params.amplicon_sizes.min == 200 + assert valid_primer_amplicon_params.amplicon_sizes.opt == 250 + assert valid_primer_amplicon_params.amplicon_sizes.max == 300 + assert valid_primer_amplicon_params.primer_gcs.min == 45.0 + assert valid_primer_amplicon_params.primer_gcs.opt == 55.0 + assert valid_primer_amplicon_params.primer_gcs.max == 60.0 + + +def test_probe_param_construction_valid( + valid_probe_params: ProbeParameters, +) -> None: + """Test ProbeParameters class instantiation with valid input""" + assert valid_probe_params.probe_sizes.min == 18 + assert valid_probe_params.probe_sizes.opt == 22 + assert valid_probe_params.probe_sizes.max == 30 + assert valid_probe_params.probe_tms.min == 65.0 + assert valid_probe_params.probe_tms.opt == 70.0 + assert valid_probe_params.probe_tms.max == 75.0 + assert valid_probe_params.probe_gcs.min == 45.0 + assert valid_probe_params.probe_gcs.opt == 55.0 + assert valid_probe_params.probe_gcs.max == 60.0 + mapped_dict = valid_probe_params.to_input_tags() + # `probe_excluded_regions` is not given, so we do not expect a key to be generated by `to_input_tags()` + assert Primer3InputTag.SEQUENCE_INTERNAL_EXCLUDED_REGION not in mapped_dict + +def test_probe_param_exclude_regions_construction_valid( + valid_probe_params_with_exclude_regions: ProbeParameters, +) -> None: + """Test ProbeParameters class instantiation with valid input for + `SEQUENCE_INTERNAL_EXCLUDED_REGION`; ensure `to_input_tags() generates corresponding key/value pair""" + assert valid_probe_params_with_exclude_regions.probe_excluded_regions == [ + (1, 10), + (20, 10), + (30, 10), + ] + mapped_dict = valid_probe_params_with_exclude_regions.to_input_tags() + assert mapped_dict[Primer3InputTag.SEQUENCE_INTERNAL_EXCLUDED_REGION] == [(1, 10), (20, 10), (30, 10)] + + +def test_primer_amplicon_param_construction_raises( + valid_primer_amplicon_params: PrimerAndAmpliconParameters, +) -> None: + """Test that PrimerAndAmpliconParameters post_init raises with invalid input.""" # overriding mypy here to test a case that normally would be caught by mypy with pytest.raises(ValueError, match="Primer Max Dinuc Bases must be an even number of bases"): # replace will create a new Primer instance with the provided/modified arguments - replace(valid_primer3_params, primer_max_dinuc_bases=5) + replace(valid_primer_amplicon_params, primer_max_dinuc_bases=5) with pytest.raises(TypeError, match="Amplicon sizes and primer sizes must be integers"): - replace(valid_primer3_params, amplicon_sizes=MinOptMax(min=200.0, opt=250.0, max=300.0)) # type: ignore + replace( + valid_primer_amplicon_params, + amplicon_sizes=MinOptMax(min=200.0, opt=250.0, max=300.0), # type: ignore + ) with pytest.raises(TypeError, match="Amplicon sizes and primer sizes must be integers"): - replace(valid_primer3_params, primer_sizes=MinOptMax(min=18.0, opt=21.0, max=27.0)) # type: ignore + replace(valid_primer_amplicon_params, primer_sizes=MinOptMax(min=18.0, opt=21.0, max=27.0)) # type: ignore with pytest.raises(ValueError, match="Min primer GC-clamp must be <= max primer GC-clamp"): - replace(valid_primer3_params, gc_clamp=(5, 0)) + replace(valid_primer_amplicon_params, gc_clamp=(5, 0)) + + +def test_primer_probe_param_construction_raises( + valid_probe_params: ProbeParameters, +) -> None: + """Test that Primer3Parameters post_init raises with invalid input.""" + # overriding mypy here to test a case that normally would be caught by mypy + with pytest.raises(TypeError, match="Probe sizes must be integers"): + # replace will create a new Primer instance with the provided/modified arguments + # we use `type: ignore` here to bypass mypy + replace( # type: ignore + valid_probe_params, + probe_sizes=MinOptMax(min=18.1, opt=22.1, max=30.1), # type: ignore + ) + with pytest.raises(TypeError, match="Probe melting temperatures and GC content must be floats"): + replace( # type: ignore + valid_probe_params, probe_tms=MinOptMax(min=55, opt=60, max=65) + ) + with pytest.raises(TypeError, match="Excluded regions for probe design must be"): + replace( + valid_probe_params, + probe_excluded_regions=[(1, 10), (42, 10), (30, 10), 3], # type: ignore + ) + with pytest.raises(TypeError, match="Excluded regions for probe design must be"): + replace( + valid_probe_params, + probe_excluded_regions=[(1.3, 10), (42, 10), (30, 10)], # type: ignore + ) -def test_to_input_tags_primer3_params(valid_primer3_params: Primer3Parameters) -> None: +def test_primer_amplicon_params_to_input_tags( + valid_primer_amplicon_params: PrimerAndAmpliconParameters, +) -> None: """Test that to_input_tags() works as expected""" - test_dict = valid_primer3_params.to_input_tags() + test_dict = valid_primer_amplicon_params.to_input_tags() assert test_dict[Primer3InputTag.PRIMER_NUM_RETURN] == 5 assert test_dict[Primer3InputTag.PRIMER_PRODUCT_SIZE_RANGE] == "200-300" assert test_dict[Primer3InputTag.PRIMER_PRODUCT_OPT_SIZE] == 250 @@ -65,24 +153,24 @@ def test_to_input_tags_primer3_params(valid_primer3_params: Primer3Parameters) - assert test_dict[Primer3InputTag.PRIMER_MAX_POLY_X] == 5 assert test_dict[Primer3InputTag.PRIMER_MAX_NS_ACCEPTED] == 1 assert test_dict[Primer3InputTag.PRIMER_LOWERCASE_MASKING] == 1 - ambiguous_primer_design = replace(valid_primer3_params, avoid_masked_bases=False) + ambiguous_primer_design = replace(valid_primer_amplicon_params, avoid_masked_bases=False) ambiguous_dict = ambiguous_primer_design.to_input_tags() assert ambiguous_dict[Primer3InputTag.PRIMER_LOWERCASE_MASKING] == 0 -def test_max_ampl_length(valid_primer3_params: Primer3Parameters) -> None: +def test_max_ampl_length(valid_primer_amplicon_params: PrimerAndAmpliconParameters) -> None: """Test that max_amplicon_length() returns expected int""" - assert valid_primer3_params.max_amplicon_length == 300 + assert valid_primer_amplicon_params.max_amplicon_length == 300 change_max_length = replace( - valid_primer3_params, amplicon_sizes=MinOptMax(min=200, opt=500, max=1000) + valid_primer_amplicon_params, amplicon_sizes=MinOptMax(min=200, opt=500, max=1000) ) assert change_max_length.max_amplicon_length == 1000 -def test_max_primer_length(valid_primer3_params: Primer3Parameters) -> None: +def test_max_primer_length(valid_primer_amplicon_params: PrimerAndAmpliconParameters) -> None: """Test that max_primer_length() returns expected int""" - assert valid_primer3_params.max_primer_length == 27 + assert valid_primer_amplicon_params.max_primer_length == 27 change_max_length = replace( - valid_primer3_params, primer_sizes=MinOptMax(min=18, opt=35, max=50) + valid_primer_amplicon_params, primer_sizes=MinOptMax(min=18, opt=35, max=50) ) assert change_max_length.max_primer_length == 50