Skip to content

Make tests deterministic #158

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 19, 2025
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
11 changes: 11 additions & 0 deletions primer3/thermoanalysis.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -2423,3 +2423,14 @@ class ThermoAnalysis(_ThermoAnalysis):
'''
pywarnings.warn(SNAKE_CASE_DEPRECATED_MSG % 'calc_tm')
return self.calc_tm(seq1)

cdef extern from "libprimer3.h":
const char* libprimer3_release()

def get_libprimer3_version() -> str:
'''Get the version of the underlying libprimer3 C library.

Returns:
str: The version string of libprimer3
'''
return libprimer3_release().decode('utf-8')
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ classifiers = [
dev = [
"pre-commit~=3.5.0",
"pytest~=7.4.0",
"tomli~=2.0.0",
]
docs = [
"myst-parser",
Expand Down
153 changes: 153 additions & 0 deletions tests/save_thermo_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Copyright (C) 2020-2025. Ben Pruitt & Nick Conway; Wyss Institute
# See LICENSE for full GPLv2 license.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
'''
tests.save_thermo_values
~~~~~~~~~~~~~~~~~~~~~~~

Script to generate and save gold standard thermodynamic values for
regression testing. Imports calculation and saving functions from
test_sequences.

'''

import json
import platform
import subprocess
import sys
from datetime import datetime
from importlib.metadata import (
PackageNotFoundError,
version,
)
from pathlib import Path
from typing import Dict

import tomli

from primer3 import __version__ as primer3_py_version
from primer3 import thermoanalysis

# Add the project root to the Python path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))

from tests.test_sequences import calculate_thermo_values


def get_git_info() -> Dict[str, str]:
'''Get git branch and latest tag information.

Returns:
Dictionary containing git branch and latest tag
'''
try:
branch = subprocess.check_output(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
stderr=subprocess.DEVNULL,
).decode('utf-8').strip()

latest_tag = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0'],
stderr=subprocess.DEVNULL,
).decode('utf-8').strip()

return {
'branch': branch,
'latest_tag': latest_tag,
}
except subprocess.CalledProcessError:
return {
'branch': 'unknown',
'latest_tag': 'unknown',
}


def get_dependency_versions() -> Dict[str, str]:
'''Get versions of dependencies from pyproject.toml.

Returns:
Dictionary containing versions of Python dependencies
'''
deps = {}

# Read pyproject.toml
pyproject_path = Path(__file__).parent.parent / 'pyproject.toml'
with open(pyproject_path, 'rb') as f:
pyproject = tomli.load(f)

# Get build dependencies
build_deps = pyproject.get('build-system', {}).get('requires', [])
for dep in build_deps:
name = dep.split('>=')[0].split('~=')[0].split('==')[0].strip()
try:
deps[name] = version(name)
except PackageNotFoundError:
deps[name] = 'Not installed'

# Get dev dependencies
dev_deps = pyproject.get('project', {}).get(
'optional-dependencies', {},
).get('dev', [])
for dep in dev_deps:
name = dep.split('>=')[0].split('~=')[0].split('==')[0].strip()
try:
deps[name] = version(name)
except PackageNotFoundError:
deps[name] = 'Not installed'

return deps


def save_thermo_values_with_metadata(
values,
filename='tests/thermo_standard_values.json',
) -> None:
'''Save thermodynamic values with metadata to a JSON file.'''
metadata = {
'generation_timestamp': datetime.now().isoformat(),
'primer3_py_version': primer3_py_version,
'primer3_lib_version': thermoanalysis.get_libprimer3_version(),
'python_version': sys.version,
'platform': {
'system': platform.system(),
'release': platform.release(),
'version': platform.version(),
'machine': platform.machine(),
},
'dependencies': get_dependency_versions(),
'git_info': get_git_info(),
}

output = {
'metadata': metadata,
'values': values,
}

with open(filename, 'w') as f:
json.dump(output, f, indent=2)


if __name__ == '__main__':
# Calculate standard values using the function from test_sequences
standard_values = calculate_thermo_values()

# Save standard values to a JSON file with metadata
save_thermo_values_with_metadata(
standard_values, 'tests/thermo_standard_values.json',
)

print('Gold standard values saved to tests/thermo_standard_values.json')
24 changes: 20 additions & 4 deletions tests/test_primerdesign.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ def _get_mem_usage():

class TestDesignBindings(unittest.TestCase):

def setUp(self) -> None:
'''Set up test case'''
random.seed(42) # Make random operations deterministic

def _generate_quality_list(self, length: int) -> List[int]:
'''Generate a deterministic list of quality scores

Args:
length: Length of quality score list to generate

Returns:
List of quality scores between 30 and 50 with mean around 45
'''
# Use triangular distribution to cluster scores around 45
return [
int(random.triangular(30, 50, 45))
for _ in range(length)
]

def _compare_results(
self,
binding_res: Dict[str, Any],
Expand Down Expand Up @@ -208,10 +227,7 @@ def test_compare_sim(self):
'TGAAGGCAAAATGATTAGACATATTGCATTAAGGTAAAAAATGATAACTGAAGAATTATGTGCCA'
'CACTTATTAATAAGAAAGAATATGTGAACCTTGCAGATGTTTCCCTCTAGTAG'
)
quality_list = [
random.randint(20, 90)
for i in range(len(sequence_template))
]
quality_list = self._generate_quality_list(len(sequence_template))
seq_args = {
'SEQUENCE_ID': 'MH1000',
'SEQUENCE_TEMPLATE': sequence_template,
Expand Down
Loading
Loading