diff --git a/prymer/primer3/primer3.py b/prymer/primer3/primer3.py index 308c1d4..a00051e 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,8 +374,8 @@ def design_primers(self, design_input: Primer3Input) -> Primer3Result: # noqa: design_region: Span = self._create_design_region( target_region=design_input.target, - max_amplicon_length=design_input.params.max_amplicon_length, - min_primer_length=design_input.params.min_primer_length, + max_amplicon_length=design_input.primer_and_amplicon_params.max_amplicon_length, + min_primer_length=design_input.primer_and_amplicon_params.min_primer_length, ) soft_masked, hard_masked = self.get_design_sequences(design_region) diff --git a/prymer/primer3/primer3_input.py b/prymer/primer3/primer3_input.py index f2aea96..e9fb3ac 100644 --- a/prymer/primer3/primer3_input.py +++ b/prymer/primer3/primer3_input.py @@ -7,15 +7,17 @@ The module uses: -1. [`Primer3Parameters`][prymer.primer3.primer3_parameters.Primer3Parameters] +1. [`PrimerAndAmpliconParameters`][prymer.primer3.primer3_parameters.Primer3Parameters] to specify user-specified criteria for primer design -2. [`PrimerAndAmpliconWeights`][prymer.primer3.primer3_weights.PrimerAndAmpliconWeights] +2. [`ProbeParameters`][prymer.primer3.primer3_parameters.ProbeParameters] + to specify user-specified criteria for probe design +3. [`PrimerAndAmpliconWeights`][prymer.primer3.primer3_weights.PrimerAndAmpliconWeights] to establish penalties based on those criteria -3. [`ProbeWeights`][prymer.primer3.primer3_weights.ProbeWeights] to specify penalties based on probe +4. [`ProbeWeights`][prymer.primer3.primer3_weights.ProbeWeights] to specify penalties based on probe design criteria -4. [`Primer3Task`][prymer.primer3.primer3_task.Primer3Task] to organize task-specific +5. [`Primer3Task`][prymer.primer3.primer3_task.Primer3Task] to organize task-specific logic. -5. [`Span`](index.md#prymer.api.span.Span] to specify the target region. +6. [`Span`](index.md#prymer.api.span.Span] to specify the target region. The `Primer3Input.to_input_tags(]` method The main purpose of this class is to generate the @@ -31,14 +33,18 @@ >>> from prymer.primer3 import DesignLeftPrimersTask >>> target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) >>> design_region = Span(refname="chr1", start=150, end=300, 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), \ primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ -) ->>> design_input = Primer3Input(target=target, params=params, task=DesignLeftPrimersTask()) + ) +>>> design_input = Primer3Input(target=target, \ + primer_and_amplicon_params=params, \ + task=DesignLeftPrimersTask() \ + ) + >>> for tag, value in design_input.to_input_tags(design_region=design_region).items(): \ print(f"{tag.value} -> {value}") PRIMER_TASK -> pick_primer_list @@ -81,13 +87,16 @@ PRIMER_WT_TM_GT -> 1.0 """ +from dataclasses import MISSING from dataclasses import dataclass +from dataclasses import fields from typing import Any from typing import Optional from prymer.api.span import Span 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 from prymer.primer3.primer3_task import Primer3TaskType from prymer.primer3.primer3_weights import PrimerAndAmpliconWeights from prymer.primer3.primer3_weights import ProbeWeights @@ -95,14 +104,31 @@ @dataclass(frozen=True, init=True, slots=True) class Primer3Input: - """Assembles necessary inputs for Primer3 to orchestrate primer and/or primer pair design.""" + """Assembles necessary inputs for Primer3 to orchestrate primer, primer pair, and/or internal + probe design.""" target: Span task: Primer3TaskType - params: Primer3Parameters - primer_weights: Optional[PrimerAndAmpliconWeights] = PrimerAndAmpliconWeights() + primer_and_amplicon_params: Optional[PrimerAndAmpliconParameters] = None + probe_params: Optional[ProbeParameters] = None + primer_weights: Optional[PrimerAndAmpliconWeights] = None probe_weights: Optional[ProbeWeights] = None + def __post_init__(self) -> None: + # check for at least one set of params + # for the set of params given, check that weights were given; use defaults if not given + if self.primer_and_amplicon_params is None and self.probe_params is None: + raise ValueError( + "Primer3 requires at least one set of parameters" + " for either primer or probe design" + ) + + if self.primer_and_amplicon_params is not None and self.primer_weights is None: + object.__setattr__(self, "primer_weights", PrimerAndAmpliconWeights()) + + if self.probe_params is not None and self.probe_weights is None: + object.__setattr__(self, "probe_weights", ProbeWeights()) + def to_input_tags(self, design_region: Span) -> dict[Primer3InputTag, Any]: """Assembles `Primer3InputTag` and values for input to `Primer3` @@ -118,10 +144,15 @@ def to_input_tags(self, design_region: Span) -> dict[Primer3InputTag, Any]: primer3_task_params = self.task.to_input_tags( design_region=design_region, target=self.target ) - assembled_tags = { - **primer3_task_params, - **self.params.to_input_tags(), - **self.primer_weights.to_input_tags(), - **(self.probe_weights.to_input_tags() if self.probe_weights is not None else {}), + assembled_tags: dict[Primer3InputTag, Any] = {**primer3_task_params} + + optional_attributes = { + field.name: getattr(self, field.name) + for field in fields(self) + if field.default is not MISSING } + for settings in optional_attributes.values(): + if settings is not None: + assembled_tags.update(settings.to_input_tags()) + return assembled_tags diff --git a/prymer/primer3/primer3_parameters.py b/prymer/primer3/primer3_parameters.py index 3438ef7..0a610bd 100644 --- a/prymer/primer3/primer3_parameters.py +++ b/prymer/primer3/primer3_parameters.py @@ -1,26 +1,29 @@ """ -# Primer3Parameters Class and Methods +# PrimerAndAmpliconParameters and ProbeParameters: Classes 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 for primer design and maps it to the correct Primer3 fields. +The [`ProbeParameters`][prymer.primer3.primer3_parameters.ProbeParameters] +class stores user input for internal probe design 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 +56,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 @@ -105,7 +110,7 @@ def __post_init__(self) -> None: def to_input_tags(self) -> dict[Primer3InputTag, Any]: """Converts input params to Primer3InputTag to feed directly into Primer3.""" - mapped_dict = { + mapped_dict: dict[Primer3InputTag, Any] = { Primer3InputTag.PRIMER_NUM_RETURN: self.number_primers_return, Primer3InputTag.PRIMER_PRODUCT_OPT_SIZE: self.amplicon_sizes.opt, Primer3InputTag.PRIMER_PRODUCT_SIZE_RANGE: ( @@ -145,3 +150,114 @@ def max_primer_length(self) -> int: def min_primer_length(self) -> int: """Minimum primer length.""" return int(self.primer_sizes.min) + + +@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, + stacklevel=2, + ) + + +@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 max GC content for individual probes + number_probes_return: the number of probes to return + probe_max_dinuc_bases: the max number of bases in a dinucleotide run in a probe + 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 + probe_excluded_region: the excluded region (start, length) that probes shall not overlap + + + 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. + + N.B., Primer3 uses the same field, `PRIMER_NUM_RETURN` to determine how many oligos to return + in the case of either primer or probe design. + The default number of oligos to return is 5 in the Primer3 manual. + + """ + probe_sizes: MinOptMax[int] + probe_tms: MinOptMax[float] + probe_gcs: MinOptMax[float] + number_probes_return: int = 5 + probe_max_dinuc_bases: int = 4 + 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 + probe_excluded_region: Optional[tuple[int, int]] = None + + 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_max_dinuc_bases % 2 == 1: + raise ValueError("Max threshold for dinucleotide bases must be an even number of bases") + if self.probe_excluded_region is not None: + # if probe_excluded regions are provided, ensure it matches tuple[int, int] + if not ( + isinstance(self.probe_excluded_region, tuple) + and all(isinstance(param, int) for param in self.probe_excluded_region) + ): + raise TypeError( + "Excluded region for probe design must be given as a tuple[int, int]" + "for start and length of region (e.g., (10,20))" + ) + + def to_input_tags(self) -> dict[Primer3InputTag, Any]: + """Converts input params to Primer3InputTag to feed directly into Primer3.""" + mapped_dict: dict[Primer3InputTag, Any] = { + 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_NUM_RETURN: self.number_probes_return, + 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_region is not None: + mapped_dict[Primer3InputTag.SEQUENCE_INTERNAL_EXCLUDED_REGION] = ( + f"{self.probe_excluded_region[0]},{self.probe_excluded_region[1]}" + ) + + return mapped_dict diff --git a/tests/primer3/test_primer3.py b/tests/primer3/test_primer3.py index 1ed3693..ca3b145 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,9 @@ 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 +154,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 +202,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 +249,15 @@ 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 +332,9 @@ 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 +344,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 +402,9 @@ 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 +415,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 +434,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 +454,16 @@ 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 +472,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 +502,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 +511,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..12e4a23 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,116 @@ 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_region=(1, 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() + # because `probe_excluded_region` is not given, we do not expect a key in `mapped_dict` + 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` and ensure `to_input_tags() generates + corresponding key/value pair.""" + assert valid_probe_params_with_exclude_regions.probe_excluded_region == (1, 10) # expect tuple + mapped_dict = valid_probe_params_with_exclude_regions.to_input_tags() + assert mapped_dict[Primer3InputTag.SEQUENCE_INTERNAL_EXCLUDED_REGION] == "1,10" # expect string + + +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( + 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(valid_probe_params, probe_tms=MinOptMax(min=55, opt=60, max=65)) + with pytest.raises(TypeError, match="Excluded region for probe design must be given"): + replace( + valid_probe_params, + probe_excluded_region=[("x", 10)], # type: ignore + ) + with pytest.raises(TypeError, match="Excluded region for probe design must be given"): + replace( + valid_probe_params, + probe_excluded_region=[(1.3, 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 +149,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