diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77b48704..e216385c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,10 +69,6 @@ jobs: - os: windows-latest python-version: '3.14' - env: - JOB_ID: ${{ strategy.job-index }} - NUM_JOBS: ${{ strategy.job-total }} - steps: - uses: actions/checkout@v7 with: @@ -88,19 +84,6 @@ jobs: run: | mkdir C:\a xcopy D:\a C:\a /s /e - - name: Determine if downloads are enabled for this job - # for testing, limit downloads from the resource servers to only the selected job for - # PRs and the main branch; note that the main branch is tested weekly via `cron`, - # so this ensures all Python versions will be periodically integration tested with the - # resource servers - if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }} - shell: bash - run: | - SELECTED_JOB=$((10#$(date +%V) % $NUM_JOBS)) - if [[ $SELECTED_JOB == $JOB_ID ]]; then - # set environment variable to download resources for selected job - echo "DOWNLOADS_ENABLED=true" >> $GITHUB_ENV - fi - name: Install dependencies run: | pip install pytest-cov sybil @@ -144,3 +127,74 @@ jobs: - name: Test with pytest run: | pytest --cov=snps tests README.md + + # Everyday CI runs fully offline against fixture-backed resources. The live-integration + # job below is the only one that contacts the real resource servers (Zenodo, S3, + # Ensembl, NCBI). It runs weekly and on changes to `main`. + # + # To exercise every OS x Python combination against the live servers (and the full + # offline suite) over time without running them all every week, this job picks one + # combination per ISO week; over a full rotation all combinations are covered. + live-matrix: + needs: [test] + if: ${{ github.repository == 'apriha/snps' && (github.event_name == 'schedule' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.base_ref == 'main')) }} + runs-on: ubuntu-latest + outputs: + os: ${{ steps.pick.outputs.os }} + python-version: ${{ steps.pick.outputs.python-version }} + steps: + - name: Pick this week's OS / Python combination + id: pick + shell: bash + run: | + oses=(ubuntu-latest macos-latest windows-latest) + pys=(3.9 3.10 3.11 3.12 3.13 3.14) + n=$(( ${#oses[@]} * ${#pys[@]} )) + idx=$(( 10#$(date -u +%V) % n )) + os="${oses[$(( idx / ${#pys[@]} ))]}" + py="${pys[$(( idx % ${#pys[@]} ))]}" + echo "Selected for this week: $os / Python $py" + echo "os=$os" >> "$GITHUB_OUTPUT" + echo "python-version=$py" >> "$GITHUB_OUTPUT" + + # A single-entry matrix from the selector so the chosen combination shows in the + # job name (e.g. "live-integration (ubuntu-latest, 3.14)"). + live-integration: + needs: [live-matrix] + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: ["${{ needs.live-matrix.outputs.os }}"] + python-version: ["${{ needs.live-matrix.outputs.python-version }}"] + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Ensure Python and source code are on same drive (Windows) + if: ${{ runner.os == 'Windows' }} + shell: cmd + run: | + mkdir C:\a + xcopy D:\a C:\a /s /e + - name: Install dependencies + run: | + pip install pytest-cov sybil + pip install . + - name: Test with pytest against the resource servers (Ubuntu & macOS) + if: ${{ runner.os != 'Windows' }} + env: + DOWNLOADS_ENABLED: "true" + run: | + pytest --cov=snps tests README.md + - name: Test with pytest against the resource servers (Windows) + if: ${{ runner.os == 'Windows' }} + working-directory: C:\a\snps\snps + env: + DOWNLOADS_ENABLED: "true" + run: | + pytest --cov=snps tests README.md diff --git a/.gitignore b/.gitignore index 98ecec51..98eebb26 100644 --- a/.gitignore +++ b/.gitignore @@ -116,6 +116,8 @@ tests/resources/* !tests/resources/gsa_chrpos_map.txt !tests/resources/gsa_rsid_map.txt !tests/resources/dbsnp_151_37_reverse.txt +!tests/resources/chip_clusters.tsv +!tests/resources/low_quality_snps.tsv tests/input/23andme.txt.zip tests/input/discrepant_snps[12].csv tests/input/ftdna.csv.gz diff --git a/README.md b/README.md index 8c2e9da2..2f0aab1f 100644 --- a/README.md +++ b/README.md @@ -73,14 +73,10 @@ genotype files from the following DNA testing sources: Additionally, `snps` can read a variety of "generic" CSV and TSV files. -## Dependencies +## Requirements -`snps` requires [Python](https://www.python.org) 3.9+ and the following Python -packages: - -- [numpy](http://www.numpy.org) -- [pandas](http://pandas.pydata.org) -- [atomicwrites](https://github.com/untitaker/python-atomicwrites) +`snps` requires [Python](https://www.python.org) 3.9+; its dependencies are specified in +`pyproject.toml` and installed automatically by `pip`. ## Installation @@ -116,7 +112,7 @@ Load a raw data file exported from a DNA testing source (e.g., ```python >>> from snps import SNPs ->>> s = SNPs("resources/sample1.23andme.txt.gz") +>>> s = SNPs(paths[0]) ``` `snps` automatically detects the source format and [normalizes](https://snps.readthedocs.io/en/stable/snps.html#snps.snps.SNPs.snps) the data: @@ -147,7 +143,7 @@ The SNPs are available as a `pandas.DataFrame`: Combine SNPs from multiple files (e.g., combine data from different testing companies): ```python ->>> results = s.merge([SNPs("resources/sample2.ftdna.csv.gz")]) +>>> results = s.merge([SNPs(paths[1])]) >>> s.count 1006949 ``` @@ -190,6 +186,9 @@ assembly. This ensures the REF alleles in the VCF are accurate: All output files are saved to the [output directory](https://snps.readthedocs.io/en/stable/output_files.html). +Downloaded resources are cached automatically; set the `SNPS_DATA_DIR` environment +variable to control where they are stored. + ### Generate Synthetic Data Generate synthetic genotype data for testing, examples, or demonstrations: diff --git a/conftest.py b/conftest.py index cc649ab1..e2bf18fe 100644 --- a/conftest.py +++ b/conftest.py @@ -1,19 +1,35 @@ -""" -Pytest configuration for testing code examples in README.md using Sybil. +"""Pytest configuration: offline resources by default + README doctests via Sybil. -This conftest.py enables Sybil to parse and test Python code blocks in the -README.md file as part of the pytest test suite. The PythonCodeBlockParser -evaluates fenced Python code blocks (```python), while SkipParser allows -selective skipping of examples using Markdown comments when needed. +An autouse fixture injects a fixture-backed resource provider so the entire suite +(including the README examples) runs with zero network and zero mocks. The Sybil +configuration enables testing Python code blocks in README.md as part of pytest: +``PythonCodeBlockParser`` evaluates fenced ``python`` blocks, while ``SkipParser`` +allows selectively skipping examples with Markdown comments. """ +import pytest from sybil import Sybil from sybil.parsers.markdown import PythonCodeBlockParser, SkipParser +from snps.resources import set_default_provider +from tests.support import FakeResources + + +@pytest.fixture(autouse=True) +def _offline_resources(): + """Use a fixture-backed resource provider for every test (no network, no mocks).""" + set_default_provider(FakeResources()) + try: + yield + finally: + set_default_provider(None) + + pytest_collect_file = Sybil( parsers=[ PythonCodeBlockParser(), SkipParser(), ], patterns=["README.md"], + fixtures=["_offline_resources"], ).pytest() diff --git a/pyproject.toml b/pyproject.toml index 9d4b6e8d..98b309c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,12 @@ classifiers = [ "Topic :: Scientific/Engineering :: Information Analysis", "Topic :: Utilities", ] -dependencies = ["numpy", "pandas", "atomicwrites"] +dependencies = [ + "numpy", + "pandas", + "atomicwrites", + "pooch", +] [project.optional-dependencies] ezancestry = ["ezancestry"] diff --git a/src/snps/resources.py b/src/snps/resources.py index e7e17fc3..0260a862 100644 --- a/src/snps/resources.py +++ b/src/snps/resources.py @@ -19,36 +19,70 @@ import json import logging import os -import socket import tarfile import tempfile -import urllib.error -import urllib.request +from typing import Protocol import numpy as np import pandas as pd -from atomicwrites import atomic_write +import pooch from snps.constants import REFERENCE_SEQUENCE_CHROMS from snps.ensembl import EnsemblRestClient -from snps.utils import Singleton, create_dir +from snps.utils import create_dir logger = logging.getLogger(__name__) -class Resources(metaclass=Singleton): +class ResourceProvider(Protocol): + """Interface consumed by ``snps`` operations to obtain external resources. + + Operations depend on this interface rather than on the network directly, so they + can be exercised with any implementation (e.g., the pooch-backed :class:`Resources` + or a fixture-backed test double). + """ + + def get_assembly_mapping_data(self, source_assembly, target_assembly): ... + + def get_chip_clusters(self): ... + + def get_low_quality_snps(self): ... + + def get_gsa_rsid(self): ... + + def get_gsa_chrpos(self): ... + + def get_dbsnp_151_37_reverse(self): ... + + def get_reference_sequences( + self, assembly="GRCh37", chroms=REFERENCE_SEQUENCE_CHROMS + ): ... + + def get_par_lookup(self, rsid): ... + + +class Resources: """Object used to manage resources required by `snps`.""" - def __init__(self, resources_dir="resources"): + def __init__(self, resources_dir=None): """Initialize a ``Resources`` object. Parameters ---------- - resources_dir : str - name / path of resources directory + resources_dir : str, optional + path to the directory used to cache downloaded resources; defaults to the + ``SNPS_DATA_DIR`` environment variable if set, else an OS-specific cache + directory (``pooch.os_cache("snps")``) """ + if resources_dir is None: + resources_dir = os.environ.get("SNPS_DATA_DIR") or pooch.os_cache("snps") self._resources_dir = os.path.abspath(resources_dir) self._ensembl_rest_client = EnsemblRestClient() + # NCBI Variation Services client for PAR SNP lookups; reused across lookups so + # its rate limiter is preserved (a fresh client per lookup would reset it) + self._ncbi_rest_client = EnsemblRestClient( + server="https://api.ncbi.nlm.nih.gov", reqs_per_sec=1 + ) self._init_resource_attributes() def _init_resource_attributes(self): @@ -145,9 +179,7 @@ def create_example_datasets(self, output_dir=None): -------- >>> from snps.resources import Resources >>> r = Resources() - >>> paths = r.create_example_datasets() - Creating resources/sample1.23andme.txt.gz - Creating resources/sample2.ftdna.csv.gz + >>> paths = r.create_example_datasets() # doctest: +SKIP """ from snps.io.generator import SyntheticSNPGenerator @@ -237,7 +269,7 @@ def get_chip_clusters(self): https://doi.org/10.1016/j.csbj.2021.06.040 """ if self._chip_clusters is None: - chip_clusters_path = self._download_file( + chip_clusters_path = self._fetch( "https://zenodo.org/records/5047472/files/the_list.tsv.gz", "chip_clusters.tsv.gz", ) @@ -279,7 +311,7 @@ def get_low_quality_snps(self): https://doi.org/10.1016/j.csbj.2021.06.040 """ if self._low_quality_snps is None: - low_quality_snps_path = self._download_file( + low_quality_snps_path = self._fetch( "https://zenodo.org/records/5047472/files/badalleles.tsv.gz", "low_quality_snps.tsv.gz", ) @@ -327,7 +359,7 @@ def get_dbsnp_151_37_reverse(self): """ if self._dbsnp_151_37_reverse is None: # download the file from the cloud, if not done already - dbsnp_rev_path = self._download_file( + dbsnp_rev_path = self._fetch( "https://sano-public.s3.eu-west-2.amazonaws.com/dbsnp151.b37.snps_reverse.txt.gz", "dbsnp_151_37_reverse.txt.gz", ) @@ -360,18 +392,6 @@ def get_dbsnp_151_37_reverse(self): return self._dbsnp_151_37_reverse - @staticmethod - def _write_data_to_gzip(f, data): - """Write `data` to `f` in `gzip` format. - - Parameters - ---------- - f : file object opened with `mode="wb"` - data : `bytes` object - """ - with gzip.open(f, "wb") as f_gzip: - f_gzip.write(data) - @staticmethod def _load_assembly_mapping_data(filename): """Load assembly mapping data. @@ -487,7 +507,7 @@ def _get_paths_reference_sequences( assembly, chroms, urls, - list(map(self._download_file, urls, local_filenames)), + list(map(self._fetch, urls, local_filenames)), ) def _create_reference_sequences(self, assembly, chroms, urls, paths): @@ -560,8 +580,10 @@ def _get_path_assembly_mapping_data( def _download_assembly_mapping_data( self, destination, chroms, source_assembly, target_assembly, retries ): - with atomic_write(destination, mode="wb", overwrite=True) as f: - with tarfile.open(fileobj=f, mode="w:gz") as out_tar: + fd, tmp_path = tempfile.mkstemp(dir=self._resources_dir, suffix=".tar.gz") + os.close(fd) + try: + with tarfile.open(tmp_path, mode="w:gz") as out_tar: for chrom in chroms: file = chrom + ".json" @@ -591,6 +613,12 @@ def _download_assembly_mapping_data( # remove temp file os.remove(f_tmp.name) + os.replace(tmp_path, destination) + except BaseException: + if os.path.exists(tmp_path): + os.remove(tmp_path) + raise + def get_gsa_rsid(self): """Get and load GSA RSID map. @@ -602,7 +630,7 @@ def get_gsa_rsid(self): """ if self._gsa_rsid_map is None: # download the file from the cloud, if not done already - rsid_path = self._download_file( + rsid_path = self._fetch( "https://sano-public.s3.eu-west-2.amazonaws.com/gsa_rsid_map.txt.gz", "gsa_rsid_map.txt.gz", ) @@ -629,7 +657,7 @@ def get_gsa_chrpos(self): """ if self._gsa_chrpos_map is None: # download the file from the cloud, if not done already - chrpos_path = self._download_file( + chrpos_path = self._fetch( "https://sano-public.s3.eu-west-2.amazonaws.com/gsa_chrpos_map.txt.gz", "gsa_chrpos_map.txt.gz", ) @@ -652,80 +680,121 @@ def get_gsa_chrpos(self): self._gsa_chrpos_map = chrpos return self._gsa_chrpos_map - def _download_file(self, url, filename, compress=False, timeout=30): - """Download a file to the resources folder. + def get_par_lookup(self, rsid): + """Look up the dbSNP RefSNP snapshot for a PAR SNP via the NCBI Variation Services API. - Download data from `url`, save as `filename`, and optionally compress with gzip. + Parameters + ---------- + rsid : str + RSID to look up (e.g., "rs28736870") + + Returns + ------- + dict + RefSNP snapshot (following merges), else None + + References + ---------- + 1. National Center for Biotechnology Information, Variation Services, RefSNP, + https://api.ncbi.nlm.nih.gov/variation/v0/ + """ + return self._lookup_refsnp_snapshot(rsid, self._ncbi_rest_client) + + def _lookup_refsnp_snapshot(self, rsid, rest_client): + id = rsid.split("rs")[1] + response = rest_client.perform_rest_action("/variation/v0/refsnp/" + id) + if "merged_snapshot_data" in response: + # this RefSnp id was merged into another + # we'll pick the first one to decide which chromosome this PAR will be assigned to + merged_id = "rs" + response["merged_snapshot_data"]["merged_into"][0] + logger.info(f"SNP id {rsid} has been merged into id {merged_id}") + return self._lookup_refsnp_snapshot(merged_id, rest_client) + elif "nosnppos_snapshot_data" in response: + logger.warning(f"Unable to look up SNP id {rsid}") + return None + else: + return response + + def _fetch(self, url, filename): + """Fetch a file into the resources cache, downloading only if not already present. Parameters ---------- url : str - URL to download data from + URL to download data from (``http(s)://`` or ``ftp://``) filename : str - name of file to save; if compress, ensure '.gz' is appended - compress : bool - compress with gzip - timeout : int - seconds for timeout of download request + relative path / name under the resources directory to save as Returns ------- str - path to downloaded file, empty str if error + path to the cached file, empty str if an error occurred """ - if compress and filename[-3:] != ".gz": - filename += ".gz" - destination = os.path.join(self._resources_dir, filename) + if os.path.exists(destination): + return destination + if not create_dir(os.path.dirname(destination)): return "" - if not os.path.exists(destination): - try: - # get file if it hasn't already been downloaded - # http://stackoverflow.com/a/7244263 - with urllib.request.urlopen(url, timeout=timeout) as response: - with atomic_write(destination, mode="wb", overwrite=True) as f: - self._print_download_msg(destination) - data = response.read() # a `bytes` object - - if compress: - self._write_data_to_gzip(f, data) - else: - f.write(data) - except urllib.error.URLError as err: - logger.warning(err) - destination = "" - # try HTTP if an FTP error occurred - if "ftp://" in url: - destination = self._download_file( - url.replace("ftp://", "http://"), - filename, - compress=compress, - timeout=timeout, - ) - except socket.timeout: - logger.warning(f"Timeout downloading {url}") - destination = "" - except FileExistsError: - # if the file exists, another process has created it while it was - # being downloaded - # in such a case, the other copy is identical, so ignore this error - pass + downloader = ( + pooch.FTPDownloader(timeout=30) + if url.startswith("ftp://") + else pooch.HTTPDownloader(timeout=30) + ) - return destination + try: + return pooch.retrieve( + url, + known_hash=None, + fname=filename, + path=self._resources_dir, + downloader=downloader, + ) + except Exception as err: + logger.warning(err) + # fall back to HTTP if an FTP download failed (Ensembl serves both) + if url.startswith("ftp://"): + return self._fetch(url.replace("ftp://", "http://", 1), filename) + return "" - @staticmethod - def _print_download_msg(path): - """Print download message. - Parameters - ---------- - path : str - path to file being downloaded - """ - logger.info(f"Downloading {os.path.relpath(path)}") +_default_provider = None + + +def set_default_provider(provider): + """Set the process-wide default resource provider used by ``SNPs`` when none is injected. + + Intended for tests (to inject a fixture-backed provider); production leaves this unset. + + Parameters + ---------- + provider : ResourceProvider or None + provider to use as the default, or None to clear the override + """ + global _default_provider + _default_provider = provider + + +def get_default_provider(resources_dir=None): + """Get the default resource provider. + + Returns the override set via :func:`set_default_provider` if one is set, else a new + pooch-backed :class:`Resources` instance. + + Parameters + ---------- + resources_dir : str, optional + cache directory passed through to a new ``Resources`` when no override is set + + Returns + ------- + ResourceProvider + """ + if _default_provider is not None: + return _default_provider + return Resources(resources_dir=resources_dir) class ReferenceSequence: diff --git a/src/snps/snps.py b/src/snps/snps.py index b10fab88..ff827a98 100644 --- a/src/snps/snps.py +++ b/src/snps/snps.py @@ -12,9 +12,8 @@ from pandas.api.types import CategoricalDtype from snps.build_constants import BUILD_MARKER_SNPS -from snps.ensembl import EnsemblRestClient from snps.io import Reader, Writer, get_empty_snps_dataframe -from snps.resources import Resources +from snps.resources import get_default_provider from snps.utils import Parallelizer logger = logging.getLogger(__name__) @@ -27,13 +26,14 @@ def __init__( only_detect_source=False, assign_par_snps=False, output_dir="output", - resources_dir="resources", + resources_dir=None, deduplicate=True, deduplicate_XY_chrom=True, deduplicate_MT_chrom=True, parallelize=False, processes=os.cpu_count(), rsids=(), + resources=None, ): """Object used to read, write, and remap genotype / raw data files. @@ -47,8 +47,9 @@ def __init__( assign PAR SNPs to the X and Y chromosomes output_dir : str path to output directory - resources_dir : str - name / path of resources directory + resources_dir : str, optional + path to the resources cache directory used by the default provider; if not + set, an OS-specific cache directory is used (ignored if `resources` is given) deduplicate : bool deduplicate RSIDs and make SNPs available as `SNPs.duplicate` deduplicate_MT_chrom : bool @@ -62,6 +63,10 @@ def __init__( processes to launch if multiprocessing rsids : tuple, optional rsids to extract if loading a VCF file + resources : ResourceProvider, optional + provider used to obtain external resources (assembly maps, GSA maps, + chip clusters, reference sequences, etc.); defaults to a pooch-backed + provider that downloads and caches resources as needed """ self._file = file self._only_detect_source = only_detect_source @@ -79,7 +84,7 @@ def __init__( self._build_detected = False self._build_original = 0 self._output_dir = output_dir - self._resources = Resources(resources_dir=resources_dir) + self._resources = resources or get_default_provider(resources_dir) self._parallelizer = Parallelizer(parallelize=parallelize, processes=processes) self._cluster = "" self._chip = "" @@ -854,12 +859,9 @@ def _assign_par_snps(self): rs28736870, rs113313554, and rs758419898 (dbSNP Build ID: 151). Available from: http://www.ncbi.nlm.nih.gov/SNP/ """ - rest_client = EnsemblRestClient( - server="https://api.ncbi.nlm.nih.gov", reqs_per_sec=1 - ) for rsid in self._snps.loc[self._snps["chrom"] == "PAR"].index.values: if "rs" in rsid: - response = self._lookup_refsnp_snapshot(rsid, rest_client) + response = self._resources.get_par_lookup(rsid) if response is not None: for item in response["primary_snapshot_data"][ @@ -878,21 +880,6 @@ def _assign_par_snps(self): self._build_detected = True break - def _lookup_refsnp_snapshot(self, rsid, rest_client): - id = rsid.split("rs")[1] - response = rest_client.perform_rest_action("/variation/v0/refsnp/" + id) - if "merged_snapshot_data" in response: - # this RefSnp id was merged into another - # we'll pick the first one to decide which chromosome this PAR will be assigned to - merged_id = "rs" + response["merged_snapshot_data"]["merged_into"][0] - logger.info(f"SNP id {rsid} has been merged into id {merged_id}") - return self._lookup_refsnp_snapshot(merged_id, rest_client) - elif "nosnppos_snapshot_data" in response: - logger.warning(f"Unable to look up SNP id {rsid}") - return None - else: - return response - def _assign_snp(self, rsid, alleles, chrom): # only assign SNP if positions match (i.e., same build) for allele in alleles: @@ -1185,10 +1172,10 @@ def sort(self): def remap(self, target_assembly, complement_bases=True): """Remap SNP coordinates from one assembly to another. - This method uses the assembly map endpoint of the Ensembl REST API service (via - ``Resources``'s ``EnsemblRestClient``) to convert SNP coordinates / positions from one - assembly to another. After remapping, the coordinates / positions for the - SNPs will be that of the target assembly. + This method converts SNP coordinates / positions from one assembly to another + using assembly mapping data from the resource provider (by default sourced from + the Ensembl REST API assembly map endpoint). After remapping, the coordinates / + positions for the SNPs will be that of the target assembly. If the SNPs are already mapped relative to the target assembly, remapping will not be performed. diff --git a/src/snps/utils.py b/src/snps/utils.py index 1f5f32a5..49ad2ffb 100644 --- a/src/snps/utils.py +++ b/src/snps/utils.py @@ -55,16 +55,6 @@ def __call__(self, f, tasks): return map(f, tasks) -class Singleton(type): - # https://stackoverflow.com/a/6798042 - _instances = {} - - def __call__(cls, *args, **kwargs): - if cls not in cls._instances: - cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) - return cls._instances[cls] - - def create_dir(path): """Create directory specified by `path` if it doesn't already exist. diff --git a/tests/__init__.py b/tests/__init__.py index d0cb7978..42f1d947 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,15 +2,17 @@ import shutil import tempfile from unittest import TestCase -from unittest.mock import Mock, PropertyMock, patch import numpy as np import pandas as pd from pandas.api.types import is_object_dtype, is_string_dtype, is_unsigned_integer_dtype from snps import SNPs +from snps.resources import set_default_provider from snps.testing import SNPsTestMixin, create_simulated_snp_df from snps.utils import gzip_file, zip_file +from tests.support import FakeResources, chip_clusters_df, low_quality_snps_df +from tests.support import data as _data class BaseSNPsTestCase(SNPsTestMixin, TestCase): @@ -45,9 +47,7 @@ def simulate_snps( return s def load_assign_PAR_SNPs(self, path): - """Load and assign PAR SNPs. - - If downloads are not enabled, use a minimal subset of the real responses. + """Load and assign PAR SNPs offline via the fixture-backed resource provider. Parameters ---------- @@ -61,9 +61,6 @@ def load_assign_PAR_SNPs(self, path): ---------- 1. National Center for Biotechnology Information, Variation Services, RefSNP, https://api.ncbi.nlm.nih.gov/variation/v0/ - 2. Yates et. al. (doi:10.1093/bioinformatics/btu613), - ``_ - 3. Zerbino et. al. (doi.org/10.1093/nar/gkx1098), https://doi.org/10.1093/nar/gkx1098 4. Sherry ST, Ward MH, Kholodov M, Baker J, Phan L, Smigielski EM, Sirotkin K. dbSNP: the NCBI database of genetic variation. Nucleic Acids Res. 2001 Jan 1; 29(1):308-11. @@ -72,321 +69,24 @@ def load_assign_PAR_SNPs(self, path): rs28736870, rs113313554, rs758419898, and rs113378274 (dbSNP Build ID: 151). Available from: http://www.ncbi.nlm.nih.gov/SNP/ """ - effects = [ - { - "refsnp_id": "758419898", - "create_date": "2015-04-1T22:25Z", - "last_update_date": "2019-07-14T04:19Z", - "last_update_build_id": "153", - "primary_snapshot_data": { - "placements_with_allele": [ - { - "seq_id": "NC_000024.9", - "placement_annot": { - "seq_id_traits_by_assembly": [ - {"assembly_name": "GRCh37.p13"} - ] - }, - "alleles": [ - { - "allele": { - "spdi": { - "seq_id": "NC_000024.9", - "position": 7364103, - } - } - } - ], - } - ] - }, - }, - { - "refsnp_id": "28736870", - "create_date": "2005-05-24T14:43Z", - "last_update_date": "2019-07-14T04:18Z", - "last_update_build_id": "153", - "primary_snapshot_data": { - "placements_with_allele": [ - { - "seq_id": "NC_000023.10", - "placement_annot": { - "seq_id_traits_by_assembly": [ - {"assembly_name": "GRCh37.p13"} - ] - }, - "alleles": [ - { - "allele": { - "spdi": { - "seq_id": "NC_000023.10", - "position": 220769, - } - } - } - ], - } - ] - }, - }, - { - "refsnp_id": "113313554", - "create_date": "2010-07-4T18:13Z", - "last_update_date": "2019-07-14T04:18Z", - "last_update_build_id": "153", - "primary_snapshot_data": { - "placements_with_allele": [ - { - "seq_id": "NC_000024.9", - "placement_annot": { - "seq_id_traits_by_assembly": [ - {"assembly_name": "GRCh37.p13"} - ] - }, - "alleles": [ - { - "allele": { - "spdi": { - "seq_id": "NC_000024.9", - "position": 535257, - } - } - } - ], - } - ] - }, - }, - { - "refsnp_id": "113378274", - "create_date": "2010-07-4T18:14Z", - "last_update_date": "2016-03-3T10:51Z", - "last_update_build_id": "147", - "merged_snapshot_data": {"merged_into": ["72608386"]}, - }, - { - "refsnp_id": "72608386", - "create_date": "2009-02-14T01:08Z", - "last_update_date": "2019-07-14T04:05Z", - "last_update_build_id": "153", - "primary_snapshot_data": { - "placements_with_allele": [ - { - "seq_id": "NC_000023.10", - "placement_annot": { - "seq_id_traits_by_assembly": [ - {"assembly_name": "GRCh37.p13"} - ] - }, - "alleles": [ - { - "allele": { - "spdi": { - "seq_id": "NC_000023.10", - "position": 91941055, - } - } - } - ], - } - ] - }, - }, - ] - - if self.downloads_enabled: - return SNPs(path, assign_par_snps=True, deduplicate_XY_chrom=False) - else: - mock = Mock(side_effect=effects) - with patch("snps.ensembl.EnsemblRestClient.perform_rest_action", mock): - return SNPs(path, assign_par_snps=True, deduplicate_XY_chrom=False) + # PAR lookups are served by the active (fixture-backed) default provider; do not + # replace it here so callers can inject specific data (e.g., remap mappings). + return SNPs(path, assign_par_snps=True, deduplicate_XY_chrom=False) def _get_test_assembly_mapping_data(self, source, target, strands, mappings): - return { - "1": { - "mappings": [ - { - "original": { - "seq_region_name": "1", - "strand": strands[0], - "start": mappings[0], - "end": mappings[0], - "assembly": f"{source}", - }, - "mapped": { - "seq_region_name": "1", - "strand": strands[1], - "start": mappings[1], - "end": mappings[1], - "assembly": f"{target}", - }, - }, - { - "original": { - "seq_region_name": "1", - "strand": strands[2], - "start": mappings[2], - "end": mappings[2], - "assembly": f"{source}", - }, - "mapped": { - "seq_region_name": "1", - "strand": strands[3], - "start": mappings[3], - "end": mappings[3], - "assembly": f"{target}", - }, - }, - { - "original": { - "seq_region_name": "1", - "strand": strands[4], - "start": mappings[4], - "end": mappings[4], - "assembly": f"{source}", - }, - "mapped": { - "seq_region_name": "1", - "strand": strands[5], - "start": mappings[5], - "end": mappings[5], - "assembly": f"{target}", - }, - }, - ] - }, - "3": { - "mappings": [ - { - "original": { - "seq_region_name": "3", - "strand": strands[6], - "start": mappings[6], - "end": mappings[6], - "assembly": f"{source}", - }, - "mapped": { - "seq_region_name": "3", - "strand": strands[7], - "start": mappings[7], - "end": mappings[7], - "assembly": f"{target}", - }, - } - ] - }, - } + return _data.get_test_assembly_mapping_data(source, target, strands, mappings) def NCBI36_GRCh37(self): - return self._get_test_assembly_mapping_data( - "NCBI36", - "GRCh37", - [1, 1, 1, 1, 1, 1, 1, -1], - [ - 742429, - 752566, - 143649677, - 144938320, - 143649678, - 144938321, - 50908372, - 50927009, - ], - ) + return _data.NCBI36_GRCh37() def GRCh37_NCBI36(self): - return self._get_test_assembly_mapping_data( - "GRCh37", - "NCBI36", - [1, 1, 1, 1, 1, 1, 1, -1], - [ - 752566, - 742429, - 144938320, - 143649677, - 144938321, - 143649678, - 50927009, - 50908372, - ], - ) + return _data.GRCh37_NCBI36() def GRCh37_GRCh38(self): - return self._get_test_assembly_mapping_data( - "GRCh37", - "GRCh38", - [1, 1, 1, -1, 1, -1, 1, 1], - [ - 752566, - 817186, - 144938320, - 148946169, - 144938321, - 148946168, - 50927009, - 50889578, - ], - ) + return _data.GRCh37_GRCh38() def GRCh37_GRCh38_PAR(self): - return { - "X": { - "mappings": [ - { - "original": { - "seq_region_name": "X", - "strand": 1, - "start": 220770, - "end": 220770, - "assembly": "GRCh37", - }, - "mapped": { - "seq_region_name": "X", - "strand": 1, - "start": 304103, - "end": 304103, - "assembly": "GRCh38", - }, - }, - { - "original": { - "seq_region_name": "X", - "strand": 1, - "start": 91941056, - "end": 91941056, - "assembly": "GRCh37", - }, - "mapped": { - "seq_region_name": "X", - "strand": 1, - "start": 92686057, - "end": 92686057, - "assembly": "GRCh38", - }, - }, - ] - }, - "Y": { - "mappings": [ - { - "original": { - "seq_region_name": "Y", - "strand": 1, - "start": 535258, - "end": 535258, - "assembly": "GRCh37", - }, - "mapped": { - "seq_region_name": "Y", - "strand": 1, - "start": 624523, - "end": 624523, - "assembly": "GRCh38", - }, - } - ] - }, - } + return _data.GRCh37_GRCh38_PAR() def snps_NCBI36(self): return self.create_snp_df( @@ -641,18 +341,20 @@ def make_parsing_assertions_vcf( self.make_normalized_dataframe_assertions(snps.snps) def get_low_quality_snps(self, pos=(104, 106, 1001), cluster="c1"): - df = pd.DataFrame( - {"chrom": ["1"] * len(pos), "pos": pos, "cluster": [cluster] * len(pos)}, - columns=["chrom", "pos", "cluster"], - ) - df.chrom = df.chrom.astype(pd.CategoricalDtype(ordered=False)) - df.pos = df.pos.astype(np.uint32) - df.cluster = df.cluster.astype(pd.CategoricalDtype(ordered=False)) - return df + return low_quality_snps_df(pos=pos, cluster=cluster) def run_low_quality_snps_test(self, f, low_quality_snps, cluster="c1"): - mock1 = PropertyMock(return_value=cluster) - mock2 = Mock(return_value=low_quality_snps) - with patch("snps.SNPs.cluster", mock1): - with patch("snps.resources.Resources.get_low_quality_snps", mock2): - f() + # Cluster detection runs the real overlap computation (no mocks) against these + # chip clusters. The generic test SNPs are rs1-rs8 at positions 101-108. + if cluster: + # clusters overlap the SNPs, so `cluster` is detected + chip_clusters = chip_clusters_df(tuple(range(101, 109)), cluster, 8) + else: + # clusters do not overlap the SNPs, so no cluster is detected + chip_clusters = chip_clusters_df(tuple(range(1001, 1009)), "c1", 8) + set_default_provider( + FakeResources( + chip_clusters=chip_clusters, low_quality_snps=low_quality_snps + ) + ) + f() diff --git a/tests/io/test_reader.py b/tests/io/test_reader.py index ef366870..e38c98a0 100644 --- a/tests/io/test_reader.py +++ b/tests/io/test_reader.py @@ -3,38 +3,11 @@ from atomicwrites import atomic_write -from snps.resources import Resources from snps.utils import gzip_file from tests import BaseSNPsTestCase class TestReader(BaseSNPsTestCase): - @staticmethod - def _setup_gsa_test(resources_dir): - # reset resource if already loaded - r = Resources() - r._resources_dir = resources_dir - r._init_resource_attributes() - - gzip_file( - "tests/resources/gsa_rsid_map.txt", - os.path.join(resources_dir, "gsa_rsid_map.txt.gz"), - ) - gzip_file( - "tests/resources/gsa_chrpos_map.txt", - os.path.join(resources_dir, "gsa_chrpos_map.txt.gz"), - ) - gzip_file( - "tests/resources/dbsnp_151_37_reverse.txt", - os.path.join(resources_dir, "dbsnp_151_37_reverse.txt.gz"), - ) - - @staticmethod - def _teardown_gsa_test(): - r = Resources() - r._resources_dir = "resources" - r._init_resource_attributes() - def run_build_detection_test( self, run_parsing_tests_func, @@ -132,17 +105,11 @@ def test_read_ancestry_multi_sep(self): def test_read_codigo46(self): # https://codigo46.com.mx - with tempfile.TemporaryDirectory() as tmpdir: - self._setup_gsa_test(tmpdir) - self.run_parsing_tests("tests/input/codigo46.txt", "Codigo46") - self._teardown_gsa_test() + self.run_parsing_tests("tests/input/codigo46.txt", "Codigo46") def test_read_tellmeGen(self): # https://www.tellmegen.com/ - with tempfile.TemporaryDirectory() as tmpdir: - self._setup_gsa_test(tmpdir) - self.run_parsing_tests("tests/input/tellmeGen.txt", "tellmeGen") - self._teardown_gsa_test() + self.run_parsing_tests("tests/input/tellmeGen.txt", "tellmeGen") def test_read_DNALand(self): # https://dna.land/ @@ -315,10 +282,7 @@ def test_read_myheritage_extra_quotes(self): def test_read_sano(self): # https://sanogenetics.com - with tempfile.TemporaryDirectory() as tmpdir: - self._setup_gsa_test(tmpdir) - self.run_parsing_tests("tests/input/sano.txt", "Sano") - self._teardown_gsa_test() + self.run_parsing_tests("tests/input/sano.txt", "Sano") def test_read_sano_dtc(self): # https://sanogenetics.com diff --git a/tests/io/test_writer.py b/tests/io/test_writer.py index 155ee0b2..dc03b93e 100755 --- a/tests/io/test_writer.py +++ b/tests/io/test_writer.py @@ -4,12 +4,13 @@ import numpy as np from snps import SNPs -from snps.resources import ReferenceSequence, Resources -from snps.utils import gzip_file from tests import BaseSNPsTestCase class TestWriter(BaseSNPsTestCase): + # Reference sequences for VCF output are served offline by the default + # fixture-backed resource provider (the committed generic.fa for chrom "1"). + def run_writer_test( self, func_str, filename="", output_file="", expected_output="", **kwargs ): @@ -17,37 +18,29 @@ def run_writer_test( with tempfile.TemporaryDirectory() as tmpdir1: s = SNPs("tests/input/testvcf.vcf", output_dir=tmpdir1) - r = Resources() - r._reference_sequences["GRCh37"] = {} - output = os.path.join(tmpdir1, output_file) - with tempfile.TemporaryDirectory() as tmpdir2: - dest = os.path.join(tmpdir2, "generic.fa.gz") - gzip_file("tests/input/generic.fa", dest) - - seq = ReferenceSequence(ID="1", path=dest) - - r._reference_sequences["GRCh37"]["1"] = seq - if not filename: - result = s.to_vcf(**kwargs) - else: - result = s.to_vcf(filename, **kwargs) + if not filename: + result = s.to_vcf(**kwargs) + else: + result = s.to_vcf(filename, **kwargs) - self.assertEqual(result, output) + self.assertEqual(result, output) - if expected_output: - # read result - with open(output, "r") as f: - actual = f.read() + if expected_output: + # read result + with open(output, "r") as f: + actual = f.read() - # read expected result - with open(expected_output, "r") as f: - expected = f.read() + # read expected result + with open(expected_output, "r") as f: + expected = f.read() - self.assertIn(expected, actual) + self.assertIn(expected, actual) - self.run_parsing_tests_vcf(output) + # the generated VCF records the reference assembly in its contig lines, + # so the build is detected when the output is re-parsed + self.run_parsing_tests_vcf(output, build_detected=True) else: with tempfile.TemporaryDirectory() as tmpdir: snps = SNPs("tests/input/generic.csv", output_dir=tmpdir) @@ -116,57 +109,36 @@ def test_save_snps_vcf_false_positive_build(self): with tempfile.TemporaryDirectory() as tmpdir1: snps = SNPs("tests/input/testvcf.vcf", output_dir=tmpdir1) - r = Resources() - r._reference_sequences["GRCh37"] = {} - output = os.path.join(tmpdir1, "vcf_GRCh37.vcf") - with tempfile.TemporaryDirectory() as tmpdir2: - dest = os.path.join(tmpdir2, "generic.fa.gz") - gzip_file("tests/input/generic.fa", dest) - - seq = ReferenceSequence(ID="1", path=dest) - - r._reference_sequences["GRCh37"]["1"] = seq - - self.assertEqual(snps.to_vcf(), output) + self.assertEqual(snps.to_vcf(), output) - s = "" - with open(output, "r") as f: - for line in f.readlines(): - if "snps v" in line: - s += '##source="vcf; snps v1.2.3.post85.dev0+gb386302; https://pypi.org/project/snps/"\n' - else: - s += line + s = "" + with open(output, "r") as f: + for line in f.readlines(): + if "snps v" in line: + s += '##source="vcf; snps v1.2.3.post85.dev0+gb386302; https://pypi.org/project/snps/"\n' + else: + s += line - with open(output, "w") as f: - f.write(s) + with open(output, "w") as f: + f.write(s) - self.run_parsing_tests_vcf(output) + self.run_parsing_tests_vcf(output, build_detected=True) def test_save_snps_vcf_discrepant_pos(self): with tempfile.TemporaryDirectory() as tmpdir1: s = SNPs("tests/input/testvcf.vcf", output_dir=tmpdir1) - r = Resources() - r._reference_sequences["GRCh37"] = {} - output = os.path.join(tmpdir1, "vcf_GRCh37.vcf") - with tempfile.TemporaryDirectory() as tmpdir2: - dest = os.path.join(tmpdir2, "generic.fa.gz") - gzip_file("tests/input/generic.fa", dest) - - seq = ReferenceSequence(ID="1", path=dest) - r._reference_sequences["GRCh37"]["1"] = seq + # create discrepant SNPs by setting positions outside reference sequence + s._snps.loc["rs1", "pos"] = 0 + s._snps.loc["rs17", "pos"] = 118 - # create discrepant SNPs by setting positions outside reference sequence - s._snps.loc["rs1", "pos"] = 0 - s._snps.loc["rs17", "pos"] = 118 + # esnure this is the right type after manual tweaking + s._snps = s._snps.astype({"pos": np.uint32}) - # esnure this is the right type after manual tweaking - s._snps = s._snps.astype({"pos": np.uint32}) - - self.assertEqual(s.to_vcf(), output) + self.assertEqual(s.to_vcf(), output) self.assert_frame_equal_with_string_index( s.discrepant_vcf_position, @@ -180,31 +152,20 @@ def test_save_snps_vcf_discrepant_pos(self): ) expected = self.generic_snps_vcf().drop(["rs1", "rs17"]) - self.run_parsing_tests_vcf(output, snps_df=expected) + self.run_parsing_tests_vcf(output, snps_df=expected, build_detected=True) def test_save_snps_vcf_phased(self): with tempfile.TemporaryDirectory() as tmpdir1: # read phased data s = SNPs("tests/input/testvcf_phased.vcf", output_dir=tmpdir1) - # setup resource to use test FASTA reference sequence - r = Resources() - r._reference_sequences["GRCh37"] = {} - output = os.path.join(tmpdir1, "vcf_GRCh37.vcf") - with tempfile.TemporaryDirectory() as tmpdir2: - dest = os.path.join(tmpdir2, "generic.fa.gz") - gzip_file("tests/input/generic.fa", dest) - seq = ReferenceSequence(ID="1", path=dest) - - r._reference_sequences["GRCh37"]["1"] = seq - - # save phased data to VCF - self.assertEqual(s.to_vcf(), output) + # save phased data to VCF + self.assertEqual(s.to_vcf(), output) # read saved VCF - self.run_parsing_tests_vcf(output, phased=True) + self.run_parsing_tests_vcf(output, phased=True, build_detected=True) def test_save_snps_phased(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -223,40 +184,28 @@ def f(): with tempfile.TemporaryDirectory() as tmpdir1: s = SNPs("tests/input/generic.csv", output_dir=tmpdir1) - # setup resource to use test FASTA reference sequence - r = Resources() - r._reference_sequences["GRCh37"] = {} - output = os.path.join(tmpdir1, "generic_GRCh37.vcf") - with tempfile.TemporaryDirectory() as tmpdir2: - dest = os.path.join(tmpdir2, "generic.fa.gz") - gzip_file("tests/input/generic.fa", dest) - seq = ReferenceSequence(ID="1", path=dest) - - r._reference_sequences["GRCh37"]["1"] = seq - - # save phased data to VCF - self.assertEqual( - s.to_vcf( - qc_only=vcf_qc_only, - qc_filter=vcf_qc_filter, - ), - output, - ) + self.assertEqual( + s.to_vcf( + qc_only=vcf_qc_only, + qc_filter=vcf_qc_filter, + ), + output, + ) - # read result - with open(output, "r") as f: - actual = f.read() + # read result + with open(output, "r") as f: + actual = f.read() - # read expected result - with open(expected_output, "r") as f: - expected = f.read() + # read expected result + with open(expected_output, "r") as f: + expected = f.read() - self.assertIn(expected, actual) + self.assertIn(expected, actual) - if not vcf_qc_filter or not cluster: - self.assertNotIn("##FILTER= `target`.""" + return { + "1": { + "mappings": [ + { + "original": { + "seq_region_name": "1", + "strand": strands[0], + "start": mappings[0], + "end": mappings[0], + "assembly": f"{source}", + }, + "mapped": { + "seq_region_name": "1", + "strand": strands[1], + "start": mappings[1], + "end": mappings[1], + "assembly": f"{target}", + }, + }, + { + "original": { + "seq_region_name": "1", + "strand": strands[2], + "start": mappings[2], + "end": mappings[2], + "assembly": f"{source}", + }, + "mapped": { + "seq_region_name": "1", + "strand": strands[3], + "start": mappings[3], + "end": mappings[3], + "assembly": f"{target}", + }, + }, + { + "original": { + "seq_region_name": "1", + "strand": strands[4], + "start": mappings[4], + "end": mappings[4], + "assembly": f"{source}", + }, + "mapped": { + "seq_region_name": "1", + "strand": strands[5], + "start": mappings[5], + "end": mappings[5], + "assembly": f"{target}", + }, + }, + ] + }, + "3": { + "mappings": [ + { + "original": { + "seq_region_name": "3", + "strand": strands[6], + "start": mappings[6], + "end": mappings[6], + "assembly": f"{source}", + }, + "mapped": { + "seq_region_name": "3", + "strand": strands[7], + "start": mappings[7], + "end": mappings[7], + "assembly": f"{target}", + }, + } + ] + }, + } + + +def NCBI36_GRCh37(): + return get_test_assembly_mapping_data( + "NCBI36", + "GRCh37", + [1, 1, 1, 1, 1, 1, 1, -1], + [ + 742429, + 752566, + 143649677, + 144938320, + 143649678, + 144938321, + 50908372, + 50927009, + ], + ) + + +def GRCh37_NCBI36(): + return get_test_assembly_mapping_data( + "GRCh37", + "NCBI36", + [1, 1, 1, 1, 1, 1, 1, -1], + [ + 752566, + 742429, + 144938320, + 143649677, + 144938321, + 143649678, + 50927009, + 50908372, + ], + ) + + +def GRCh37_GRCh38(): + return get_test_assembly_mapping_data( + "GRCh37", + "GRCh38", + [1, 1, 1, -1, 1, -1, 1, 1], + [ + 752566, + 817186, + 144938320, + 148946169, + 144938321, + 148946168, + 50927009, + 50889578, + ], + ) + + +def GRCh37_GRCh38_PAR(): + return { + "X": { + "mappings": [ + { + "original": { + "seq_region_name": "X", + "strand": 1, + "start": 220770, + "end": 220770, + "assembly": "GRCh37", + }, + "mapped": { + "seq_region_name": "X", + "strand": 1, + "start": 304103, + "end": 304103, + "assembly": "GRCh38", + }, + }, + { + "original": { + "seq_region_name": "X", + "strand": 1, + "start": 91941056, + "end": 91941056, + "assembly": "GRCh37", + }, + "mapped": { + "seq_region_name": "X", + "strand": 1, + "start": 92686057, + "end": 92686057, + "assembly": "GRCh38", + }, + }, + ] + }, + "Y": { + "mappings": [ + { + "original": { + "seq_region_name": "Y", + "strand": 1, + "start": 535258, + "end": 535258, + "assembly": "GRCh37", + }, + "mapped": { + "seq_region_name": "Y", + "strand": 1, + "start": 624523, + "end": 624523, + "assembly": "GRCh38", + }, + } + ] + }, + } + + +def standard_assembly_mappings(): + """Default ``(source, target) -> mapping data`` map served by ``FakeResources``.""" + return { + ("NCBI36", "GRCh37"): NCBI36_GRCh37(), + ("GRCh37", "NCBI36"): GRCh37_NCBI36(), + ("GRCh37", "GRCh38"): GRCh37_GRCh38(), + } + + +# RefSNP snapshots for PAR SNPs, keyed by RefSNP id (incl. one merged snapshot). +PAR_EFFECTS = [ + { + "refsnp_id": "758419898", + "create_date": "2015-04-1T22:25Z", + "last_update_date": "2019-07-14T04:19Z", + "last_update_build_id": "153", + "primary_snapshot_data": { + "placements_with_allele": [ + { + "seq_id": "NC_000024.9", + "placement_annot": { + "seq_id_traits_by_assembly": [{"assembly_name": "GRCh37.p13"}] + }, + "alleles": [ + { + "allele": { + "spdi": {"seq_id": "NC_000024.9", "position": 7364103} + } + } + ], + } + ] + }, + }, + { + "refsnp_id": "28736870", + "create_date": "2005-05-24T14:43Z", + "last_update_date": "2019-07-14T04:18Z", + "last_update_build_id": "153", + "primary_snapshot_data": { + "placements_with_allele": [ + { + "seq_id": "NC_000023.10", + "placement_annot": { + "seq_id_traits_by_assembly": [{"assembly_name": "GRCh37.p13"}] + }, + "alleles": [ + { + "allele": { + "spdi": {"seq_id": "NC_000023.10", "position": 220769} + } + } + ], + } + ] + }, + }, + { + "refsnp_id": "113313554", + "create_date": "2010-07-4T18:13Z", + "last_update_date": "2019-07-14T04:18Z", + "last_update_build_id": "153", + "primary_snapshot_data": { + "placements_with_allele": [ + { + "seq_id": "NC_000024.9", + "placement_annot": { + "seq_id_traits_by_assembly": [{"assembly_name": "GRCh37.p13"}] + }, + "alleles": [ + { + "allele": { + "spdi": {"seq_id": "NC_000024.9", "position": 535257} + } + } + ], + } + ] + }, + }, + { + "refsnp_id": "113378274", + "create_date": "2010-07-4T18:14Z", + "last_update_date": "2016-03-3T10:51Z", + "last_update_build_id": "147", + "merged_snapshot_data": {"merged_into": ["72608386"]}, + }, + { + "refsnp_id": "72608386", + "create_date": "2009-02-14T01:08Z", + "last_update_date": "2019-07-14T04:05Z", + "last_update_build_id": "153", + "primary_snapshot_data": { + "placements_with_allele": [ + { + "seq_id": "NC_000023.10", + "placement_annot": { + "seq_id_traits_by_assembly": [{"assembly_name": "GRCh37.p13"}] + }, + "alleles": [ + { + "allele": { + "spdi": {"seq_id": "NC_000023.10", "position": 91941055} + } + } + ], + } + ] + }, + }, +] + + +def chip_clusters_df(pos=tuple(range(101, 109)), cluster="c1", length=8): + """Build a parsed chip-clusters DataFrame (as returned by ``get_chip_clusters``).""" + df = pd.DataFrame( + {"chrom": ["1"] * length, "pos": pos, "clusters": [cluster] * length}, + columns=["chrom", "pos", "clusters"], + ) + df.chrom = df.chrom.astype(pd.CategoricalDtype(ordered=False)) + df.pos = df.pos.astype(np.uint32) + df.clusters = df.clusters.astype(pd.CategoricalDtype(ordered=False)) + return df + + +def low_quality_snps_df(pos=(104, 106, 1001), cluster="c1"): + """Build a parsed low-quality-SNPs DataFrame (as returned by ``get_low_quality_snps``).""" + df = pd.DataFrame( + {"chrom": ["1"] * len(pos), "pos": pos, "cluster": [cluster] * len(pos)}, + columns=["chrom", "pos", "cluster"], + ) + df.chrom = df.chrom.astype(pd.CategoricalDtype(ordered=False)) + df.pos = df.pos.astype(np.uint32) + df.cluster = df.cluster.astype(pd.CategoricalDtype(ordered=False)) + return df diff --git a/tests/support/fake_resources.py b/tests/support/fake_resources.py new file mode 100644 index 00000000..5bfc0d78 --- /dev/null +++ b/tests/support/fake_resources.py @@ -0,0 +1,100 @@ +"""Fixture-backed ``ResourceProvider`` for offline tests (no network, no mocks).""" + +import atexit +import os +import shutil +import tempfile + +from snps.resources import Resources +from snps.utils import create_dir, gzip_file +from tests.support.data import PAR_EFFECTS, standard_assembly_mappings + +_TESTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_RESOURCES_DIR = os.path.join(_TESTS_DIR, "resources") +_INPUT_DIR = os.path.join(_TESTS_DIR, "input") + +# committed raw fixtures served in place of downloads, keyed by cache filename +_FIXTURES = { + "gsa_rsid_map.txt.gz": "gsa_rsid_map.txt", + "gsa_chrpos_map.txt.gz": "gsa_chrpos_map.txt", + "dbsnp_151_37_reverse.txt.gz": "dbsnp_151_37_reverse.txt", + "chip_clusters.tsv.gz": "chip_clusters.tsv", + "low_quality_snps.tsv.gz": "low_quality_snps.tsv", +} + +# shared cache dir so committed fixtures are gzipped once and reused across instances +_CACHE_DIR = tempfile.mkdtemp(prefix="snps-fake-resources-") +atexit.register(shutil.rmtree, _CACHE_DIR, ignore_errors=True) + +_PAR_LOOKUPS = {e["refsnp_id"]: e for e in PAR_EFFECTS} + + +class FakeResources(Resources): + """Offline ``ResourceProvider`` backed by committed fixtures. + + Inherits all parsing / transforms / aggregators from :class:`~snps.resources.Resources` + and overrides only the byte source (``_fetch``) plus the two REST-built methods + (``get_assembly_mapping_data``, ``get_par_lookup``). Optional keyword arguments inject + pre-built data for tests that need specific resources. + + Parameters + ---------- + chip_clusters : pandas.DataFrame, optional + value returned by ``get_chip_clusters`` (defaults to the committed fixture) + low_quality_snps : pandas.DataFrame, optional + value returned by ``get_low_quality_snps`` (defaults to the committed fixture) + assembly_mapping_data : dict or callable, optional + value returned by ``get_assembly_mapping_data``; a callable is invoked with + ``(source_assembly, target_assembly)`` (defaults to the standard test mappings) + resources_dir : str, optional + cache directory used when gzipping committed fixtures (defaults to a shared tmp dir) + """ + + def __init__( + self, + *, + chip_clusters=None, + low_quality_snps=None, + assembly_mapping_data=None, + resources_dir=None, + ): + super().__init__(resources_dir=resources_dir or _CACHE_DIR) + self._chip_clusters = chip_clusters + self._low_quality_snps = low_quality_snps + self._assembly_mapping_data = assembly_mapping_data + + def _fetch(self, url, filename): + base = os.path.basename(filename) + if base in _FIXTURES: + src = os.path.join(_RESOURCES_DIR, _FIXTURES[base]) + elif filename.replace(os.sep, "/").startswith("fasta/") and base.endswith( + ".fa.gz" + ): + src = os.path.join(_INPUT_DIR, "generic.fa") + else: + return "" + + destination = os.path.join(self._resources_dir, filename) + create_dir(os.path.dirname(destination)) + if not os.path.exists(destination): + gzip_file(src, destination) + return destination + + def get_assembly_mapping_data(self, source_assembly, target_assembly): + if self._assembly_mapping_data is not None: + data = self._assembly_mapping_data + if callable(data): + return data(source_assembly, target_assembly) + return data + return standard_assembly_mappings().get((source_assembly, target_assembly), {}) + + def get_par_lookup(self, rsid): + snapshot = _PAR_LOOKUPS.get(rsid.split("rs")[1]) + if snapshot is None: + return None + if "merged_snapshot_data" in snapshot: + merged = "rs" + snapshot["merged_snapshot_data"]["merged_into"][0] + return self.get_par_lookup(merged) + if "nosnppos_snapshot_data" in snapshot: + return None + return snapshot diff --git a/tests/test_resources.py b/tests/test_resources.py index 9e169082..66cab3bc 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,373 +1,75 @@ -import gzip import os -import socket import tempfile -import urllib.error -from unittest.mock import Mock, mock_open, patch +import unittest import numpy as np -from snps import SNPs from snps.resources import ReferenceSequence, Resources from snps.utils import gzip_file from tests import BaseSNPsTestCase +from tests.support import FakeResources +# Live integration tests contact the real resource servers and run only when downloads +# are explicitly enabled (the dedicated CI live-integration job). +DOWNLOADS_ENABLED = os.getenv("DOWNLOADS_ENABLED") == "true" -class TestResources(BaseSNPsTestCase): - def _reset_resource(self): - self.resource._init_resource_attributes() - - def run(self, result=None): - # set resources directory based on if downloads are being performed - # https://stackoverflow.com/a/11180583 - self.resource = Resources() - self._reset_resource() - if self.downloads_enabled: - self.resource._resources_dir = "resources" - super().run(result) - else: - # use a temporary directory for test resource data - with tempfile.TemporaryDirectory() as tmpdir: - self.resource._resources_dir = tmpdir - super().run(result) - self.resource._resources_dir = "resources" +class TestResources(BaseSNPsTestCase): + # --- offline behavior, served by the fixture-backed provider -------------- def test_get_assembly_mapping_data(self): - def f(): - effects = [{"mappings": []} for _ in range(1, 26)] - for k, v in self.NCBI36_GRCh37().items(): - effects[int(k) - 1] = v - mock = Mock(side_effect=effects) - with patch("snps.ensembl.EnsemblRestClient.perform_rest_action", mock): - return self.resource.get_assembly_mapping_data("NCBI36", "GRCh37") - - assembly_mapping_data = ( - self.resource.get_assembly_mapping_data("NCBI36", "GRCh37") - if self.downloads_enabled - else f() - ) - - self.assertEqual(len(assembly_mapping_data), 25) + data = FakeResources().get_assembly_mapping_data("NCBI36", "GRCh37") + self.assertEqual(sorted(data.keys()), ["1", "3"]) def test_get_gsa_resources(self): - def f(): - # mock download of test data for each resource - self._generate_test_gsa_resources() - # load test resources saved to `tmpdir` - return self.resource.get_gsa_resources() - - gsa_resources = ( - self.resource.get_gsa_resources() if self.downloads_enabled else f() - ) - - self.assertEqual(len(gsa_resources["rsid_map"]), 618540) - self.assertEqual(len(gsa_resources["chrpos_map"]), 665608) - self.assertEqual(len(gsa_resources["dbsnp_151_37_reverse"]), 2393418) - - def _generate_test_gsa_resources(self): - lines = ["Name\tRsID"] - - for i in range(1, 618541): - lines.append(f"rs{i}\trs{i}") - - s = "\n".join(lines) - mock = mock_open(read_data=gzip.compress(s.encode())) - with patch("urllib.request.urlopen", mock): - self.resource.get_gsa_rsid() + gsa_resources = FakeResources().get_gsa_resources() + self.assertEqual(len(gsa_resources["rsid_map"]), 8) + self.assertEqual(len(gsa_resources["chrpos_map"]), 8) + self.assertEqual(len(gsa_resources["dbsnp_151_37_reverse"]), 2) - lines = ["Name\tChr\tMapInfo\tdeCODE(cM)"] - - for i in range(1, 665609): - lines.append(f"rs{i}\t1\t{i}\t0.0000") - - s = "\n".join(lines) - - mock = mock_open(read_data=gzip.compress(s.encode())) - with patch("urllib.request.urlopen", mock): - self.resource.get_gsa_chrpos() - - lines = ["# comment", "rs1 0.0 0.0 0.0 0.0"] - - for i in range(2, 2393419): - lines.append(f"rs{i}") - - s = "\n".join(lines) - - mock = mock_open(read_data=gzip.compress(s.encode())) - with patch("urllib.request.urlopen", mock): - self.resource.get_dbsnp_151_37_reverse() - - def test_get_all_resources(self): - def f(): - # mock download of test data for each resource - self._generate_test_gsa_resources() - self._generate_test_chip_clusters() - self._generate_test_low_quality_snps() + def test_get_chip_clusters(self): + self.assertEqual(len(FakeResources().get_chip_clusters()), 8) - # generate test data for permutations of remapping data - effects = [{"mappings": []} for _ in range(1, 26)] - for k, v in self.NCBI36_GRCh37().items(): - effects[int(k) - 1] = v - mock = Mock(side_effect=effects * 6) - with patch("snps.ensembl.EnsemblRestClient.perform_rest_action", mock): - return self.resource.get_all_resources() + def test_get_low_quality_snps(self): + self.assertEqual(len(FakeResources().get_low_quality_snps()), 3) - resources = self.resource.get_all_resources() if self.downloads_enabled else f() + def test_get_reference_sequences(self): + seqs = FakeResources().get_reference_sequences(chroms=["1"]) + self.assertEqual(len(seqs), 1) + self.assertEqual(seqs["1"].ID, "1") + self.assertEqual(seqs["1"].chrom, "1") + self.assertEqual(seqs["1"].assembly, "GRCh37") + self.assertEqual(seqs["1"].build, "B37") + self.assertEqual(len(seqs["1"].sequence), 117) + self.assertEqual(seqs["1"].md5, "6ac6176535ad0e38aba2d05d786c39b6") + + def test_get_par_lookup(self): + fr = FakeResources() + self.assertEqual(fr.get_par_lookup("rs758419898")["refsnp_id"], "758419898") + # a merged RefSNP resolves to the snapshot it was merged into + self.assertEqual(fr.get_par_lookup("rs113378274")["refsnp_id"], "72608386") - for k, v in resources.items(): - self.assertGreater(len(v), 0) + def test_get_reference_sequences_invalid_assembly(self): + self.assertEqual(FakeResources().get_reference_sequences(assembly="36"), {}) def test_get_paths_reference_sequences_invalid_assembly(self): - assembly, chroms, urls, paths = self.resource._get_paths_reference_sequences( - assembly="36" - ) + assembly, chroms, urls, paths = Resources( + resources_dir="resources" + )._get_paths_reference_sequences(assembly="36") self.assertFalse(assembly) self.assertFalse(chroms) self.assertFalse(urls) self.assertFalse(paths) - def run_reference_sequences_test(self, f, assembly="GRCh37"): - if self.downloads_enabled: - f() - else: - s = f">MT dna:chromosome chromosome:{assembly}:MT:1:16569:1 REF\n" - for i in range(276): - s += "A" * 60 - s += "\n" - s += "A" * 9 - s += "\n" - with patch( - "urllib.request.urlopen", mock_open(read_data=gzip.compress(s.encode())) - ): - f() - - def run_create_reference_sequences_test(self, assembly_expect, url_expect): - def f(): - ( - assembly, - chroms, - urls, - paths, - ) = self.resource._get_paths_reference_sequences( - assembly=assembly_expect, chroms=["MT"] - ) - seqs = self.resource._create_reference_sequences( - assembly, chroms, urls, paths - ) - self.assertEqual(len(seqs), 1) - self.assertEqual( - seqs["MT"].__repr__(), - f"ReferenceSequence(assembly='{assembly_expect}', ID='MT')", - ) - self.assertEqual(seqs["MT"].ID, "MT") - self.assertEqual(seqs["MT"].chrom, "MT") - self.assertEqual(seqs["MT"].url, f"{url_expect}") - self.assertEqual( - seqs["MT"].path, - os.path.relpath( - f"{os.path.join(self.resource._resources_dir, 'fasta', assembly_expect, os.path.basename(url_expect))}" - ), - ) - self.assertTrue(os.path.exists(seqs["MT"].path)) - self.assertEqual(seqs["MT"].assembly, assembly_expect) - self.assertEqual(seqs["MT"].build, f"B{assembly_expect[-2:]}") - self.assertEqual(seqs["MT"].species, "Homo sapiens") - self.assertEqual(seqs["MT"].taxonomy, "x") - - self.run_reference_sequences_test(f, assembly_expect) - - def test_create_reference_sequences_NCBI36(self): - self.run_create_reference_sequences_test( - "NCBI36", - "ftp://ftp.ensembl.org/pub/release-54/fasta/homo_sapiens/dna/Homo_sapiens.NCBI36.54.dna.chromosome.MT.fa.gz", - ) - - def test_create_reference_sequences_GRCh37(self): - self.run_create_reference_sequences_test( - "GRCh37", - "ftp://ftp.ensembl.org/pub/grch37/release-96/fasta/homo_sapiens/dna/Homo_sapiens.GRCh37.dna.chromosome.MT.fa.gz", - ) - - def test_create_reference_sequences_GRCh38(self): - self.run_create_reference_sequences_test( - "GRCh38", - "ftp://ftp.ensembl.org/pub/release-96/fasta/homo_sapiens/dna/Homo_sapiens.GRCh38.dna.chromosome.MT.fa.gz", - ) - - def test_create_reference_sequences_invalid_path(self): - def f(): - ( - assembly, - chroms, - urls, - paths, - ) = self.resource._get_paths_reference_sequences( - assembly="GRCh37", chroms=["MT"] - ) - paths[0] = "" - seqs = self.resource._create_reference_sequences( - assembly, chroms, urls, paths - ) - self.assertEqual(len(seqs), 0) - - self.run_reference_sequences_test(f) - - def test_download_file_socket_timeout(self): - mock = Mock(side_effect=socket.timeout) - with patch("urllib.request.urlopen", mock): - path = self.resource._download_file("http://url", "test.txt") - self.assertEqual(path, "") - - def test_download_file_URL_error(self): - mock = Mock(side_effect=urllib.error.URLError("test error")) - with patch("urllib.request.urlopen", mock): - path1 = self.resource._download_file("http://url", "test.txt") - path2 = self.resource._download_file("ftp://url", "test.txt") - self.assertEqual(path1, "") - self.assertEqual(path2, "") - - def test_get_reference_sequences(self): - def f(): - seqs = self.resource.get_reference_sequences(chroms=["MT"]) - self.assertEqual(len(seqs), 1) - self.assertEqual( - seqs["MT"].__repr__(), "ReferenceSequence(assembly='GRCh37', ID='MT')" - ) - self.assertEqual(seqs["MT"].ID, "MT") - self.assertEqual(seqs["MT"].chrom, "MT") - self.assertEqual( - seqs["MT"].url, - "ftp://ftp.ensembl.org/pub/grch37/release-96/fasta/homo_sapiens/dna/Homo_sapiens.GRCh37.dna.chromosome.MT.fa.gz", - ) - self.assertEqual( - seqs["MT"].path, - os.path.relpath( - f"{os.path.join(self.resource._resources_dir, 'fasta', 'GRCh37', 'Homo_sapiens.GRCh37.dna.chromosome.MT.fa.gz')}" - ), - ) - self.assertTrue(os.path.exists(seqs["MT"].path)) - self.assertEqual(seqs["MT"].assembly, "GRCh37") - self.assertEqual(seqs["MT"].build, "B37") - self.assertEqual(seqs["MT"].species, "Homo sapiens") - self.assertEqual(seqs["MT"].taxonomy, "x") - - self.run_reference_sequences_test(f) - - def test_get_all_reference_sequences(self): - def f(): - seqs = self.resource.get_all_reference_sequences(chroms=["MT"]) - self.assertEqual(len(seqs), 3) - self.assertEqual(len(seqs["NCBI36"]), 1) - self.assertEqual( - seqs["NCBI36"]["MT"].path, - os.path.relpath( - os.path.join( - self.resource._resources_dir, - "fasta", - "NCBI36", - "Homo_sapiens.NCBI36.54.dna.chromosome.MT.fa.gz", - ) - ), - ) - self.assertEqual(len(seqs["GRCh37"]), 1) - self.assertEqual( - seqs["GRCh37"]["MT"].path, - os.path.relpath( - os.path.join( - self.resource._resources_dir, - "fasta", - "GRCh37", - "Homo_sapiens.GRCh37.dna.chromosome.MT.fa.gz", - ) - ), - ) - self.assertEqual(len(seqs["GRCh38"]), 1) - self.assertEqual( - seqs["GRCh38"]["MT"].path, - os.path.relpath( - os.path.join( - self.resource._resources_dir, - "fasta", - "GRCh38", - "Homo_sapiens.GRCh38.dna.chromosome.MT.fa.gz", - ) - ), - ) - - self.run_reference_sequences_test(f) - - def test_get_reference_sequences_invalid_assembly(self): - seqs = self.resource.get_reference_sequences(assembly="36") - self.assertEqual(len(seqs), 0) - - def test_get_reference_sequences_chrom_not_available(self): - def f(): - self.resource.get_reference_sequences(chroms=["MT"]) - del self.resource._reference_sequences["GRCh37"]["MT"] - seqs = self.resource.get_reference_sequences(chroms=["MT"]) - self.assertEqual(len(seqs), 1) - self.assertEqual( - seqs["MT"].__repr__(), "ReferenceSequence(assembly='GRCh37', ID='MT')" - ) - self.assertEqual(seqs["MT"].ID, "MT") - self.assertEqual(seqs["MT"].chrom, "MT") - self.assertEqual( - seqs["MT"].url, - "ftp://ftp.ensembl.org/pub/grch37/release-96/fasta/homo_sapiens/dna/Homo_sapiens.GRCh37.dna.chromosome.MT.fa.gz", - ) - self.assertEqual( - seqs["MT"].path, - os.path.relpath( - os.path.join( - self.resource._resources_dir, - "fasta", - "GRCh37", - "Homo_sapiens.GRCh37.dna.chromosome.MT.fa.gz", - ) - ), - ) - self.assertTrue(os.path.exists(seqs["MT"].path)) - self.assertEqual(seqs["MT"].assembly, "GRCh37") - self.assertEqual(seqs["MT"].build, "B37") - self.assertEqual(seqs["MT"].species, "Homo sapiens") - self.assertEqual(seqs["MT"].taxonomy, "x") - - self.run_reference_sequences_test(f) - - def run_reference_sequence_load_sequence_test(self, hash): - def f(): - seqs = self.resource.get_reference_sequences(chroms=["MT"]) - self.assertEqual(len(seqs["MT"].sequence), 16569) - self.assertEqual(seqs["MT"].md5, hash) - self.assertEqual(seqs["MT"].start, 1) - self.assertEqual(seqs["MT"].end, 16569) - self.assertEqual(seqs["MT"].length, 16569) - - seqs["MT"].clear() - self.assertEqual(seqs["MT"]._sequence.size, 0) - self.assertEqual(seqs["MT"]._md5, "") - self.assertEqual(seqs["MT"]._start, 0) - self.assertEqual(seqs["MT"]._end, 0) - self.assertEqual(seqs["MT"]._length, 0) - - self.assertEqual(len(seqs["MT"].sequence), 16569) - self.assertEqual(seqs["MT"].md5, hash) - self.assertEqual(seqs["MT"].start, 1) - self.assertEqual(seqs["MT"].end, 16569) - self.assertEqual(seqs["MT"].length, 16569) - - self.run_reference_sequences_test(f) + def test_create_example_datasets(self): + with tempfile.TemporaryDirectory() as tmpdir: + paths = Resources(resources_dir=tmpdir).create_example_datasets(tmpdir) - def test_reference_sequence_load_sequence(self): - if self.downloads_enabled: - self.run_reference_sequence_load_sequence_test( - "c68f52674c9fb33aef52dcf399755519" - ) - else: - self.run_reference_sequence_load_sequence_test( - "d432324413a21aa9247321c56c300ad3" - ) + self.assertEqual(len(paths), 2) + self.assertTrue(paths[0].endswith("sample1.23andme.txt.gz")) + self.assertTrue(paths[1].endswith("sample2.ftdna.csv.gz")) + self.assertTrue(os.path.exists(paths[0])) + self.assertTrue(os.path.exists(paths[1])) def test_reference_sequence_generic_load_sequence(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -397,85 +99,84 @@ def test_reference_sequence_generic_load_sequence(self): self.assertEqual(seq.end, 117) self.assertEqual(seq.length, 117) - def test_create_example_datasets(self): - """Test creating synthetic example datasets.""" - with tempfile.TemporaryDirectory() as tmpdir: - paths = self.resource.create_example_datasets(tmpdir) - - # Verify two files were created - self.assertEqual(len(paths), 2) - self.assertTrue(os.path.exists(paths[0])) - self.assertTrue(os.path.exists(paths[1])) - - # Verify filenames - self.assertTrue(paths[0].endswith("sample1.23andme.txt.gz")) - self.assertTrue(paths[1].endswith("sample2.ftdna.csv.gz")) - - # Verify files can be loaded - s1 = SNPs(paths[0]) - s2 = SNPs(paths[1]) - - # Verify file sources are detected correctly - self.assertEqual(s1.source, "23andMe") - self.assertEqual(s2.source, "FTDNA") - - # Build detection should work with injected marker SNPs - self.assertEqual(s1.build, 37) - self.assertTrue(s1.build_detected) - self.assertEqual(s2.build, 37) - self.assertTrue(s2.build_detected) - - # Verify SNP counts are approximately correct - self.assertGreater(s1.count, 900000) - # FTDNA file only contains autosomal chromosomes (1-22), so count is lower - self.assertGreater(s2.count, 650000) + # --- cached files load without re-downloading (offline) ------------------- - # Verify 23andMe file includes all chromosomes - s1_chroms = set(s1.snps["chrom"].unique()) - self.assertIn("X", s1_chroms) - self.assertIn("Y", s1_chroms) - self.assertIn("MT", s1_chroms) - - # Verify FTDNA file only includes autosomal chromosomes (1-22) - s2_chroms = set(s2.snps["chrom"].unique()) - self.assertNotIn("X", s2_chroms) - self.assertNotIn("Y", s2_chroms) - self.assertNotIn("MT", s2_chroms) - - def _generate_test_chip_clusters(self): - s = "1:1\tc1\n" * 2135214 - mock = mock_open(read_data=gzip.compress(s.encode())) - with patch("urllib.request.urlopen", mock): - self.resource.get_chip_clusters() - - def test_get_chip_clusters(self): - def f(): - # mock download of test data for chip clusters - self._generate_test_chip_clusters() - # load test resource - return self.resource.get_chip_clusters() - - chip_clusters = ( - self.resource.get_chip_clusters() if self.downloads_enabled else f() - ) + def test_cache_layout_resolves_without_download(self): + # An already-cached resource must load without downloading; this pins the cache + # paths/filenames so existing user caches keep working. + with tempfile.TemporaryDirectory() as tmpdir: + r = Resources(resources_dir=tmpdir) + + gzip_file( + "tests/resources/chip_clusters.tsv", + os.path.join(tmpdir, "chip_clusters.tsv.gz"), + ) + gzip_file( + "tests/resources/gsa_rsid_map.txt", + os.path.join(tmpdir, "gsa_rsid_map.txt.gz"), + ) + os.makedirs(os.path.join(tmpdir, "fasta", "GRCh37")) + gzip_file( + "tests/input/generic.fa", + os.path.join( + tmpdir, + "fasta", + "GRCh37", + "Homo_sapiens.GRCh37.dna.chromosome.1.fa.gz", + ), + ) - self.assertEqual(len(chip_clusters), 2135214) + self.assertEqual(len(r.get_chip_clusters()), 8) + self.assertEqual(len(r.get_gsa_rsid()), 8) + seqs = r.get_reference_sequences(chroms=["1"]) + self.assertEqual(seqs["1"].ID, "1") + self.assertEqual(len(seqs["1"].sequence), 117) - def _generate_test_low_quality_snps(self): - s = "c1\t" + "1:1," * 56024 + "1:1\n" - mock = mock_open(read_data=gzip.compress(s.encode())) - with patch("urllib.request.urlopen", mock): - self.resource.get_low_quality_snps() + # --- live integration (real servers; gated to the live-integration job) --- - def test_get_low_quality_snps(self): - def f(): - # mock download of test data for low quality SNPs - self._generate_test_low_quality_snps() - # load test resource - return self.resource.get_low_quality_snps() + @unittest.skipUnless(DOWNLOADS_ENABLED, "live downloads disabled") + def test_get_assembly_mapping_data_live(self): + data = Resources().get_assembly_mapping_data("NCBI36", "GRCh37") + self.assertEqual(len(data), 25) - low_quality_snps = ( - self.resource.get_low_quality_snps() if self.downloads_enabled else f() - ) + @unittest.skipUnless(DOWNLOADS_ENABLED, "live downloads disabled") + def test_get_gsa_resources_live(self): + gsa_resources = Resources().get_gsa_resources() + self.assertEqual(len(gsa_resources["rsid_map"]), 618540) + self.assertEqual(len(gsa_resources["chrpos_map"]), 665608) + self.assertEqual(len(gsa_resources["dbsnp_151_37_reverse"]), 2393418) - self.assertEqual(len(low_quality_snps), 56025) + @unittest.skipUnless(DOWNLOADS_ENABLED, "live downloads disabled") + def test_get_chip_clusters_live(self): + self.assertEqual(len(Resources().get_chip_clusters()), 2135214) + + @unittest.skipUnless(DOWNLOADS_ENABLED, "live downloads disabled") + def test_get_low_quality_snps_live(self): + self.assertEqual(len(Resources().get_low_quality_snps()), 56025) + + @unittest.skipUnless(DOWNLOADS_ENABLED, "live downloads disabled") + def test_get_par_lookup_live(self): + response = Resources().get_par_lookup("rs28736870") + self.assertEqual(response["refsnp_id"], "28736870") + self.assertIn("primary_snapshot_data", response) + + @unittest.skipUnless(DOWNLOADS_ENABLED, "live downloads disabled") + def test_get_reference_sequences_live(self): + # each assembly uses a distinct Ensembl FASTA base URL, so verify all three + expected_urls = { + "NCBI36": "ftp://ftp.ensembl.org/pub/release-54/fasta/homo_sapiens/dna/Homo_sapiens.NCBI36.54.dna.chromosome.MT.fa.gz", + "GRCh37": "ftp://ftp.ensembl.org/pub/grch37/release-96/fasta/homo_sapiens/dna/Homo_sapiens.GRCh37.dna.chromosome.MT.fa.gz", + "GRCh38": "ftp://ftp.ensembl.org/pub/release-96/fasta/homo_sapiens/dna/Homo_sapiens.GRCh38.dna.chromosome.MT.fa.gz", + } + r = Resources() + for assembly, url in expected_urls.items(): + seqs = r.get_reference_sequences(assembly=assembly, chroms=["MT"]) + self.assertEqual(seqs["MT"].url, url) + self.assertEqual(seqs["MT"].assembly, assembly) + self.assertEqual(seqs["MT"].build, f"B{assembly[-2:]}") + self.assertGreater(len(seqs["MT"].sequence), 0) + + # GRCh37 MT is the rCRS; pin its exact size and hash + grch37 = r.get_reference_sequences(assembly="GRCh37", chroms=["MT"]) + self.assertEqual(len(grch37["MT"].sequence), 16569) + self.assertEqual(grch37["MT"].md5, "c68f52674c9fb33aef52dcf399755519") diff --git a/tests/test_snps.py b/tests/test_snps.py index f3be4260..cc2db5f7 100755 --- a/tests/test_snps.py +++ b/tests/test_snps.py @@ -5,14 +5,16 @@ import sys import tempfile import warnings -from unittest.mock import Mock, patch +from unittest.mock import Mock import numpy as np import pandas as pd from snps import SNPs from snps.io import get_empty_snps_dataframe +from snps.resources import set_default_provider from tests import BaseSNPsTestCase +from tests.support import FakeResources, chip_clusters_df class TestSnps(BaseSNPsTestCase): @@ -233,12 +235,8 @@ def test_only_detect_source(self): self.assertEqual(s.count, 0) def _run_remap_test(self, f, mappings): - if self.downloads_enabled: - f() - else: - mock = Mock(return_value=mappings) - with patch("snps.resources.Resources.get_assembly_mapping_data", mock): - f() + set_default_provider(FakeResources(assembly_mapping_data=mappings)) + f() def test_remap_36_to_37(self): def f(): @@ -510,19 +508,11 @@ def test_ancestry_no_snps(self): self.assertDictEqual(snps.predict_ancestry(), {}) def _get_chip_clusters(self, pos=tuple(range(101, 109)), cluster="c1", length=8): - df = pd.DataFrame( - {"chrom": ["1"] * length, "pos": pos, "clusters": [cluster] * length}, - columns=["chrom", "pos", "clusters"], - ) - df.chrom = df.chrom.astype(pd.CategoricalDtype(ordered=False)) - df.pos = df.pos.astype(np.uint32) - df.clusters = df.clusters.astype(pd.CategoricalDtype(ordered=False)) - return df + return chip_clusters_df(pos=pos, cluster=cluster, length=length) def run_cluster_test(self, f, chip_clusters): - mock = Mock(return_value=chip_clusters) - with patch("snps.resources.Resources.get_chip_clusters", mock): - f() + set_default_provider(FakeResources(chip_clusters=chip_clusters)) + f() def test_cluster(self): def f(): @@ -592,18 +582,20 @@ def f(): self.assertEqual(s.cluster, "c1") self.assertEqual(s.build, 36) # ensure copy gets remapped - mock = Mock( - return_value=self._get_test_assembly_mapping_data( - "NCBI36", - "GRCh37", - [1] * 8, - [101, 101, 102, 102, 103, 103, 0, 0], + set_default_provider( + FakeResources( + chip_clusters=self._get_chip_clusters( + pos=tuple(range(101, 104)), length=3 + ), + assembly_mapping_data=self._get_test_assembly_mapping_data( + "NCBI36", + "GRCh37", + [1] * 8, + [101, 101, 102, 102, 103, 103, 0, 0], + ), ) ) - with patch("snps.resources.Resources.get_assembly_mapping_data", mock): - self.run_cluster_test( - f, self._get_chip_clusters(pos=tuple(range(101, 104)), length=3) - ) + f() def test_snps_qc(self): def f(): @@ -656,18 +648,23 @@ def f(): ) self.assertEqual(s.build, 36) # ensure copy gets remapped - mock = Mock( - return_value=self._get_test_assembly_mapping_data( - "NCBI36", - "GRCh37", - [1] * 8, - [101, 101, 102, 102, 103, 103, 0, 0], + # cluster detection runs on the remapped (build 37) SNPs rs1-rs3 at positions + # 101-103, so the injected chip clusters match those positions to yield "c1". + set_default_provider( + FakeResources( + chip_clusters=self._get_chip_clusters( + pos=tuple(range(101, 104)), length=3 + ), + assembly_mapping_data=self._get_test_assembly_mapping_data( + "NCBI36", + "GRCh37", + [1] * 8, + [101, 101, 102, 102, 103, 103, 0, 0], + ), + low_quality_snps=self.get_low_quality_snps(pos=(102, 1001)), ) ) - with patch("snps.resources.Resources.get_assembly_mapping_data", mock): - self.run_low_quality_snps_test( - f, self.get_low_quality_snps(pos=(102, 1001)) - ) + f() class TestSNPsMerge(TestSnps): diff --git a/uv.lock b/uv.lock index 7a08d781..51fc0d36 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,15 @@ version = "1.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/87/c6/53da25344e3e3a9c01095a89f16dbcda021c609ddb42dd6d7c0528236fb2/atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11", size = 14227, upload-time = "2022-07-08T18:31:40.459Z" } +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -36,6 +45,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, + { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.1.8" @@ -268,6 +398,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -798,6 +937,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, +] + [[package]] name = "pre-commit" version = "4.3.0" @@ -987,6 +1141,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, + { name = "idna", marker = "python_full_version < '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, + { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, + { name = "idna", marker = "python_full_version >= '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1133,6 +1325,7 @@ dependencies = [ { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'extra-4-snps-ezancestry') or (extra == 'extra-4-snps-ezancestry' and extra == 'group-4-snps-dev')" }, { name = "pandas", version = "1.5.3", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-4-snps-ezancestry'" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-snps-dev' or extra != 'extra-4-snps-ezancestry'" }, + { name = "pooch" }, ] [package.optional-dependencies] @@ -1160,6 +1353,7 @@ requires-dist = [ { name = "ezancestry", marker = "extra == 'ezancestry'" }, { name = "numpy" }, { name = "pandas" }, + { name = "pooch" }, ] provides-extras = ["ezancestry"] @@ -1283,6 +1477,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "virtualenv" version = "21.5.1"