From a978220d7fda97b579c1dc4d9b12f48f3e8c7e81 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Fri, 13 Feb 2026 10:58:50 -0800 Subject: [PATCH 1/4] feat: enhance missing value handling for scoreset records --- src/dcd_mapping/mavedb_data.py | 7 ++++--- src/dcd_mapping/resource_utils.py | 35 +++++++++++++++++++++++++++++++ src/dcd_mapping/vrs_map.py | 13 ++++++++---- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/dcd_mapping/mavedb_data.py b/src/dcd_mapping/mavedb_data.py index 1dad2b2..ed0606b 100644 --- a/src/dcd_mapping/mavedb_data.py +++ b/src/dcd_mapping/mavedb_data.py @@ -27,6 +27,7 @@ MAVEDB_BASE_URL, authentication_header, http_download, + is_missing_value, ) from dcd_mapping.schemas import ( ScoreRow, @@ -246,13 +247,13 @@ def _load_scoreset_records( with path.open() as csvfile: reader = csv.DictReader(csvfile) for row in reader: - if row["score"] == "NA": + if is_missing_value(row["score"]): row["score"] = None else: row["score"] = row["score"] - if row["hgvs_nt"] != "NA": + if not is_missing_value(row["hgvs_nt"]): prefix = row["hgvs_nt"].split(":")[0] if ":" in row["hgvs_nt"] else None - elif row["hgvs_pro"] != "NA": + elif not is_missing_value(row["hgvs_pro"]): prefix = ( row["hgvs_pro"].split(":")[0] if ":" in row["hgvs_pro"] else None ) diff --git a/src/dcd_mapping/resource_utils.py b/src/dcd_mapping/resource_utils.py index ef29402..b72cc56 100644 --- a/src/dcd_mapping/resource_utils.py +++ b/src/dcd_mapping/resource_utils.py @@ -10,6 +10,25 @@ _logger = logging.getLogger(__name__) +# Common representations of missing/null data in CSV files +MISSING_VALUE_REPRESENTATIONS = frozenset( + { + "NA", + "N/A", + "na", + "n/a", + "NaN", + "nan", + "null", + "NULL", + "None", + "none", + "", + "-", + ".", + } +) + MAVEDB_API_KEY = os.environ.get("MAVEDB_API_KEY") MAVEDB_BASE_URL = os.environ.get("MAVEDB_BASE_URL") ENSEMBL_API_URL = os.environ.get("ENSEMBL_API_URL", "https://rest.ensembl.org") # TODO @@ -24,6 +43,22 @@ LOCAL_STORE_PATH.mkdir(exist_ok=True, parents=True) +def is_missing_value(value: str | None) -> bool: + """Check if a value represents missing/null data. + + This function recognizes multiple common representations of missing data + that may appear in CSV files from external sources, making the codebase + more resilient to upstream changes in NA representation. + + :param value: The value to check + :return: True if the value represents missing data, False otherwise + """ + if value is None: + return True + # Strip whitespace and check against known missing value representations + return value.strip() in MISSING_VALUE_REPRESENTATIONS + + def authentication_header() -> dict | None: """Fetch with api key envvar, if available.""" return {"X-API-key": MAVEDB_API_KEY} if MAVEDB_API_KEY is not None else None diff --git a/src/dcd_mapping/vrs_map.py b/src/dcd_mapping/vrs_map.py index ea2bf25..b36e043 100644 --- a/src/dcd_mapping/vrs_map.py +++ b/src/dcd_mapping/vrs_map.py @@ -32,7 +32,7 @@ get_seqrepo, translate_hgvs_to_vrs, ) -from dcd_mapping.resource_utils import request_with_backoff +from dcd_mapping.resource_utils import is_missing_value, request_with_backoff from dcd_mapping.schemas import ( AlignmentResult, MappedScore, @@ -378,7 +378,11 @@ def _map_protein_coding_pro( :param transcript: The transcript selection information for a score set :return: VRS mapping object if mapping succeeds """ - if row.hgvs_pro in {"_wt", "_sy", "NA"} or len(row.hgvs_pro) == 3: + if ( + row.hgvs_pro in {"_wt", "_sy"} + or is_missing_value(row.hgvs_pro) + or len(row.hgvs_pro) == 3 + ): _logger.warning( "Can't process variant syntax %s for %s", row.hgvs_pro, row.accession ) @@ -700,7 +704,7 @@ def _hgvs_nt_is_valid(hgvs_nt: str) -> bool: :return: True if expression appears populated and valid """ return ( - (hgvs_nt != "NA") + (not is_missing_value(hgvs_nt)) and (hgvs_nt not in {"_wt", "_sy", "="}) and (len(hgvs_nt) != 3) ) @@ -713,7 +717,8 @@ def _hgvs_pro_is_valid(hgvs_pro: str) -> bool: :return: True if expression appears populated and valid """ return ( - (hgvs_pro not in {"_wt", "_sy", "NA"}) + (hgvs_pro not in {"_wt", "_sy"}) + and (not is_missing_value(hgvs_pro)) and (len(hgvs_pro) != 3) and ("fs" not in hgvs_pro) ) From ecdc1cfd83637cc91f5baaaeb39e97af7b95f812 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 18 Feb 2026 16:11:01 -0800 Subject: [PATCH 2/4] feat: enhance error logging in map_scoreset function --- src/api/routers/map.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/api/routers/map.py b/src/api/routers/map.py index 3f0e9aa..9d7a15e 100644 --- a/src/api/routers/map.py +++ b/src/api/routers/map.py @@ -64,6 +64,7 @@ async def map_scoreset(urn: str, store_path: Path | None = None) -> JSONResponse records = get_scoreset_records(metadata, True, store_path) metadata = patch_target_sequence_type(metadata, records, force=False) except ScoresetNotSupportedError as e: + _logger.error("Scoreset not supported for %s: %s", urn, e) return JSONResponse( content=ScoresetMapping( metadata=None, @@ -72,6 +73,7 @@ async def map_scoreset(urn: str, store_path: Path | None = None) -> JSONResponse ) except ResourceAcquisitionError as e: msg = f"Unable to acquire resource from MaveDB: {e}" + _logger.error(msg) raise HTTPException(status_code=500, detail=msg) from e if not records: @@ -87,17 +89,21 @@ async def map_scoreset(urn: str, store_path: Path | None = None) -> JSONResponse alignment_results = build_alignment_result(metadata, True) except BlatNotFoundError as e: msg = "BLAT command appears missing. Ensure it is available on the $PATH or use the environment variable BLAT_BIN_PATH to point to it. See instructions in the README prerequisites section for more." + _logger.error("BLAT not found for %s: %s", urn, e) raise HTTPException(status_code=500, detail=msg) from e except ResourceAcquisitionError as e: msg = f"BLAT resource could not be acquired: {e}" + _logger.error(msg) raise HTTPException(status_code=500, detail=msg) from e except AlignmentError as e: + _logger.error("Alignment error for %s: %s", urn, e) return JSONResponse( content=ScoresetMapping( metadata=metadata, error_message=str(e).strip("'") ).model_dump(exclude_none=True) ) except ScoresetNotSupportedError as e: + _logger.error("Scoreset not supported during alignment for %s: %s", urn, e) return JSONResponse( content=ScoresetMapping( metadata=metadata, error_message=str(e).strip("'") @@ -113,9 +119,11 @@ async def map_scoreset(urn: str, store_path: Path | None = None) -> JSONResponse # underlying issues with data providers. except HTTPError as e: msg = f"HTTP error occurred during transcript selection: {e}" + _logger.error(msg) raise HTTPException(status_code=500, detail=msg) from e except DataLookupError as e: msg = f"Data lookup error occurred during transcript selection: {e}" + _logger.error(msg) raise HTTPException(status_code=500, detail=msg) from e vrs_results = {} @@ -134,6 +142,7 @@ async def map_scoreset(urn: str, store_path: Path | None = None) -> JSONResponse UnsupportedReferenceSequencePrefixError, MissingSequenceIdError, ) as e: + _logger.error("VRS mapping error for %s: %s", urn, e) return JSONResponse( content=ScoresetMapping( metadata=metadata, error_message=str(e).strip("'") @@ -172,6 +181,7 @@ async def map_scoreset(urn: str, store_path: Path | None = None) -> JSONResponse VrsVersion.V_2, ) except Exception as e: + _logger.error("Unexpected error during annotation for %s: %s", urn, e) return JSONResponse( content=ScoresetMapping( metadata=metadata, error_message=str(e).strip("'") @@ -287,6 +297,7 @@ async def map_scoreset(urn: str, store_path: Path | None = None) -> JSONResponse del reference_sequences[target_gene].layers[layer] except Exception as e: + _logger.error("Unexpected error during result assembly for %s: %s", urn, e) return JSONResponse( content=ScoresetMapping( metadata=metadata, error_message=str(e).strip("'") From 4bbbb8a15a48ce5f079aa36a19ddb431d401bdb1 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 18 Feb 2026 18:18:43 -0800 Subject: [PATCH 3/4] fix: replace requests with httpx across all HTTP call sites urllib3 2.6.3 seems to have a regression causing requests-based calls to rest.ensembl.org to time out at 30-70s while curl and httpx complete in ~0.8s. The root cause is in urllib3's connection handling layer; switching to httpx bypasses it entirely and restores expected latency. Rather than patching only the Ensembl calls, all HTTP call sites are migrated to ensure consistent performance throughout the codebase (MaveDB API, UCSC genome download, cdot transcript lookups, UniProt). resource_utils.py already used httpx for request_with_backoff; the remaining requests.get calls and the streaming download in http_download are updated to match. Test infrastructure is updated from requests-mock to respx and exception types are updated to their httpx equivalents (HTTPStatusError, ConnectError, TimeoutException, HTTPError). --- pyproject.toml | 4 ++-- src/api/routers/map.py | 4 ++-- src/dcd_mapping/align.py | 8 +++---- src/dcd_mapping/lookup.py | 6 ++--- src/dcd_mapping/main.py | 4 ++-- src/dcd_mapping/mavedb_data.py | 14 ++++++------ src/dcd_mapping/resource_utils.py | 20 ++++++++-------- tests/test_lookup.py | 4 ++-- tests/test_mavedb_data.py | 22 ++++++++---------- tests/test_resource_utils.py | 38 ++++++++++++++++--------------- 10 files changed, 61 insertions(+), 63 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 58c986a..1dffbc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ requires-python = ">=3.11" dependencies = [ "agct~=0.1.0", - "requests", "biopython", "tqdm", "cdot", @@ -41,6 +40,7 @@ dependencies = [ "cool-seq-tool==0.4.0.dev3", "ga4gh.vrs==2.0.0-a6", "gene_normalizer[etl,pg]==0.3.0-dev2", + "httpx~=0.28", "pydantic>=2", "python-dotenv", "setuptools>=68.0", # tmp -- ensure 3.12 compatibility @@ -61,7 +61,7 @@ tests = [ "pytest-mock", "pytest-cov", "pytest-asyncio", - "requests-mock" + "respx" ] dev = [ "ruff==0.2.0", diff --git a/src/api/routers/map.py b/src/api/routers/map.py index 9d7a15e..9235061 100644 --- a/src/api/routers/map.py +++ b/src/api/routers/map.py @@ -5,7 +5,7 @@ from cool_seq_tool.schemas import AnnotationLayer from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse -from requests import HTTPError +from httpx import HTTPStatusError from dcd_mapping.align import build_alignment_result from dcd_mapping.annotate import ( @@ -117,7 +117,7 @@ async def map_scoreset(urn: str, store_path: Path | None = None) -> JSONResponse # on the target level and on the variant level for variants relative to that target # HTTPErrors and DataLookupErrors cause the mapping process to exit because these indicate # underlying issues with data providers. - except HTTPError as e: + except HTTPStatusError as e: msg = f"HTTP error occurred during transcript selection: {e}" _logger.error(msg) raise HTTPException(status_code=500, detail=msg) from e diff --git a/src/dcd_mapping/align.py b/src/dcd_mapping/align.py index 1d25546..5f08128 100644 --- a/src/dcd_mapping/align.py +++ b/src/dcd_mapping/align.py @@ -7,7 +7,7 @@ from pathlib import Path from urllib.parse import urlparse -import requests +import httpx from Bio.SearchIO import HSP from Bio.SearchIO import parse as parse_blat from Bio.SearchIO._model import Hit, QueryResult @@ -84,7 +84,7 @@ def get_ref_genome_file( if not genome_file.exists(): try: http_download(url, genome_file, silent) - except requests.HTTPError as e: + except httpx.HTTPStatusError as e: msg = f"HTTPError when fetching reference genome file from {url}" _logger.error(msg) raise ResourceAcquisitionError(msg) from e @@ -378,11 +378,11 @@ def fetch_alignment( alignment_results[accession_id] = None else: url = f"{CDOT_URL}/transcript/{accession_id}" - r = requests.get(url, timeout=30) + r = httpx.get(url, timeout=30) try: r.raise_for_status() - except requests.HTTPError as e: + except httpx.HTTPStatusError as e: msg = f"Received HTTPError from {url} for scoreset {metadata.urn}" _logger.error(msg) raise ResourceAcquisitionError(msg) from e diff --git a/src/dcd_mapping/lookup.py b/src/dcd_mapping/lookup.py index 08cf75b..17b9cc3 100644 --- a/src/dcd_mapping/lookup.py +++ b/src/dcd_mapping/lookup.py @@ -14,8 +14,8 @@ from typing import Any import hgvs +import httpx import polars as pl -import requests from biocommons.seqrepo import SeqRepo from biocommons.seqrepo.seqaliasdb.seqaliasdb import sqlite3 from cdot.hgvs.dataproviders import ChainedSeqFetcher, FastaSeqFetcher, RESTDataProvider @@ -682,7 +682,7 @@ def get_overlapping_features_for_region( url, headers={"Content-Type": "application/json"} ) response.raise_for_status() - except requests.RequestException as e: + except httpx.HTTPError as e: _logger.error( "Failed to fetch overlapping features for region %s-%s on chromosome %s: %s", start, @@ -715,7 +715,7 @@ def get_uniprot_sequence(uniprot_id: str) -> str | None: :raise HTTPError: if response comes with an HTTP error code """ url = f"https://www.ebi.ac.uk/proteins/api/proteins?accession={uniprot_id.split(':')[1]}&format=json" - response = requests.get(url, timeout=30) + response = httpx.get(url, timeout=30) response.raise_for_status() json = response.json() return json[0]["sequence"]["sequence"] diff --git a/src/dcd_mapping/main.py b/src/dcd_mapping/main.py index 500904c..d146623 100644 --- a/src/dcd_mapping/main.py +++ b/src/dcd_mapping/main.py @@ -6,7 +6,7 @@ from pathlib import Path import click -from requests import HTTPError +from httpx import HTTPStatusError from dcd_mapping.align import build_alignment_result from dcd_mapping.annotate import ( @@ -205,7 +205,7 @@ async def map_scoreset( # on the target level and on the variant level for variants relative to that target # HTTPErrors and DataLookupErrors cause the mapping process to exit because these indicate # underlying issues with data providers. - except HTTPError as e: + except HTTPStatusError as e: _emit_info( f"HTTP error occurred during transcript selection: {e}", silent, diff --git a/src/dcd_mapping/mavedb_data.py b/src/dcd_mapping/mavedb_data.py index ed0606b..6bf0ee4 100644 --- a/src/dcd_mapping/mavedb_data.py +++ b/src/dcd_mapping/mavedb_data.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any -import requests +import httpx from fastapi import HTTPException from pydantic import ValidationError @@ -57,7 +57,7 @@ def get_scoreset_urns() -> set[str]: :return: set of URN strings """ - r = requests.get( + r = httpx.get( f"{MAVEDB_BASE_URL}/api/v1/experiments/", timeout=30, headers=authentication_header(), @@ -101,14 +101,14 @@ def get_human_urns() -> list[str]: scoreset_urns = get_scoreset_urns() human_scoresets: list[str] = [] for urn in scoreset_urns: - r = requests.get( + r = httpx.get( f"{MAVEDB_BASE_URL}/api/v1/score-sets/{urn}", timeout=30, headers=authentication_header(), ) try: r.raise_for_status() - except requests.exceptions.HTTPError: + except httpx.HTTPStatusError: _logger.info("Unable to retrieve scoreset data for URN %s", urn) continue data = r.json() @@ -156,10 +156,10 @@ def get_raw_scoreset_metadata( metadata_file = dcd_mapping_dir / f"{scoreset_urn}_metadata.json" if not metadata_file.exists(): url = f"{MAVEDB_BASE_URL}/api/v1/score-sets/{scoreset_urn}" - r = requests.get(url, timeout=30, headers=authentication_header()) + r = httpx.get(url, timeout=30, headers=authentication_header()) try: r.raise_for_status() - except requests.HTTPError as e: + except httpx.HTTPStatusError as e: msg = f"Received HTTPError from {url} for scoreset {scoreset_urn}" _logger.error(msg) raise ResourceAcquisitionError(msg) from e @@ -318,7 +318,7 @@ def get_scoreset_records( url = f"{MAVEDB_BASE_URL}/api/v1/score-sets/{metadata.urn}/scores" try: http_download(url, scores_csv, silent) - except requests.HTTPError as e: + except httpx.HTTPStatusError as e: msg = f"HTTPError when fetching scores CSV from {url}" _logger.error(msg) raise ResourceAcquisitionError(msg) from e diff --git a/src/dcd_mapping/resource_utils.py b/src/dcd_mapping/resource_utils.py index b72cc56..0ea9aa3 100644 --- a/src/dcd_mapping/resource_utils.py +++ b/src/dcd_mapping/resource_utils.py @@ -5,7 +5,7 @@ from pathlib import Path import click -import requests +import httpx from tqdm import tqdm _logger = logging.getLogger(__name__) @@ -71,13 +71,11 @@ def http_download(url: str, out_path: Path, silent: bool = True) -> Path: :param out_path: location to save file to :param silent: show TQDM progress bar if true :return: Path if download successful - :raise requests.HTTPError: if request is unsuccessful + :raise httpx.HTTPStatusError: if request is unsuccessful """ if not silent: click.echo(f"Downloading {out_path.name} to {out_path.parents[0].absolute()}") - with requests.get( - url, stream=True, timeout=60, headers=authentication_header() - ) as r: + with httpx.stream("GET", url, timeout=60, headers=authentication_header()) as r: r.raise_for_status() total_size = int(r.headers.get("content-length", 0)) with out_path.open("wb") as h: @@ -89,12 +87,12 @@ def http_download(url: str, out_path: Path, silent: bool = True) -> Path: desc=out_path.name, ncols=80, ) as progress_bar: - for chunk in r.iter_content(chunk_size=8192): + for chunk in r.iter_bytes(chunk_size=8192): if chunk: h.write(chunk) progress_bar.update(len(chunk)) else: - for chunk in r.iter_content(chunk_size=8192): + for chunk in r.iter_bytes(chunk_size=8192): if chunk: h.write(chunk) return out_path @@ -102,7 +100,7 @@ def http_download(url: str, out_path: Path, silent: bool = True) -> Path: def request_with_backoff( url: str, max_retries: int = 5, backoff_factor: float = 0.3, **kwargs -) -> requests.Response: +) -> httpx.Response: """HTTP GET with exponential backoff only for retryable errors. Retries on: @@ -115,9 +113,9 @@ def request_with_backoff( attempt = 0 while attempt < max_retries: try: - kwargs.setdefault("timeout", 60) # Default timeout of 10 seconds - response = requests.get(url, **kwargs) # noqa: S113 - except (requests.Timeout, requests.ConnectionError): + kwargs.setdefault("timeout", 60) + response = httpx.get(url, **kwargs) + except (httpx.TimeoutException, httpx.ConnectError): # Retry on transient network failures if attempt == max_retries - 1: raise diff --git a/tests/test_lookup.py b/tests/test_lookup.py index 52ef287..db2f470 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -2,7 +2,7 @@ from unittest.mock import patch -import requests +import httpx from dcd_mapping.lookup import get_overlapping_features_for_region @@ -95,7 +95,7 @@ def __init__(self): def raise_for_status(self): msg = f"HTTP {self.status_code} Error" - raise requests.RequestException(msg) + raise httpx.HTTPError(msg) with ( patch("dcd_mapping.lookup.request_with_backoff", return_value=ErrorResponse()), diff --git a/tests/test_mavedb_data.py b/tests/test_mavedb_data.py index 751d0c7..260d724 100644 --- a/tests/test_mavedb_data.py +++ b/tests/test_mavedb_data.py @@ -3,8 +3,9 @@ import shutil from pathlib import Path +import httpx import pytest -import requests_mock +import respx from dcd_mapping.mavedb_data import get_scoreset_metadata, get_scoreset_records @@ -32,10 +33,9 @@ def test_get_scoreset_metadata( resources_data_dir: Path, scoreset_metadata_response: dict ): urn = "urn:mavedb:00000093-a-1" - with requests_mock.Mocker() as m: - m.get( - f"https://api.mavedb.org/api/v1/score-sets/{urn}", - json=scoreset_metadata_response[urn], + with respx.mock: + respx.get(f"https://api.mavedb.org/api/v1/score-sets/{urn}").mock( + return_value=httpx.Response(200, json=scoreset_metadata_response[urn]) ) scoreset_metadata = get_scoreset_metadata( urn, dcd_mapping_dir=resources_data_dir @@ -62,17 +62,15 @@ def test_get_scoreset_records( urn = "urn:mavedb:00000093-a-1" with (fixture_data_dir / f"{urn}_scores.csv").open() as f: scores_csv_text = f.read() - with requests_mock.Mocker() as m: - m.get( - f"https://api.mavedb.org/api/v1/score-sets/{urn}", - json=scoreset_metadata_response[urn], + with respx.mock: + respx.get(f"https://api.mavedb.org/api/v1/score-sets/{urn}").mock( + return_value=httpx.Response(200, json=scoreset_metadata_response[urn]) ) scoreset_metadata = get_scoreset_metadata( urn, dcd_mapping_dir=resources_data_dir ) - m.get( - f"https://api.mavedb.org/api/v1/score-sets/{urn}/scores", - text=scores_csv_text, + respx.get(f"https://api.mavedb.org/api/v1/score-sets/{urn}/scores").mock( + return_value=httpx.Response(200, text=scores_csv_text) ) scoreset_records = get_scoreset_records( scoreset_metadata, dcd_mapping_dir=resources_data_dir diff --git a/tests/test_resource_utils.py b/tests/test_resource_utils.py index 6c6055a..355c1eb 100644 --- a/tests/test_resource_utils.py +++ b/tests/test_resource_utils.py @@ -3,8 +3,8 @@ from contextlib import ExitStack from unittest import mock +import httpx import pytest -import requests from dcd_mapping.resource_utils import request_with_backoff @@ -16,8 +16,10 @@ def __init__(self, status_code=200, headers=None): def raise_for_status(self): if not (200 <= self.status_code < 300): - msg = f"HTTP {self.status_code}" - raise requests.HTTPError(msg) + request = httpx.Request("GET", "http://example.com") + response = httpx.Response(self.status_code, request=request) + msg = f"HTTP {self.status_code} Error" + raise httpx.HTTPStatusError(msg, request=request, response=response) def _sequence_side_effect(values): @@ -36,7 +38,7 @@ def _next(*args, **kwargs): # noqa: ANN002 def test_success_200_returns_response(): dummy = _DummyResponse(200) with mock.patch( - "dcd_mapping.resource_utils.requests.get", return_value=dummy + "dcd_mapping.resource_utils.httpx.get", return_value=dummy ) as get_mock: resp = request_with_backoff("http://example.com/resource") assert resp is dummy @@ -44,12 +46,12 @@ def test_success_200_returns_response(): def test_timeout_then_success_retries_with_backoff(): - first_exc = requests.Timeout("timeout") + first_exc = httpx.TimeoutException("timeout") second_resp = _DummyResponse(200) with ExitStack() as stack: get_mock = stack.enter_context( mock.patch( - "dcd_mapping.resource_utils.requests.get", + "dcd_mapping.resource_utils.httpx.get", side_effect=_sequence_side_effect([first_exc, second_resp]), ) ) @@ -64,18 +66,18 @@ def test_timeout_then_success_retries_with_backoff(): def test_connection_error_until_max_raises(): - seq = [requests.ConnectionError("conn err")] * 5 + seq = [httpx.ConnectError("conn err")] * 5 with ExitStack() as stack: stack.enter_context( mock.patch( - "dcd_mapping.resource_utils.requests.get", + "dcd_mapping.resource_utils.httpx.get", side_effect=_sequence_side_effect(seq), ) ) sleep_mock = stack.enter_context( mock.patch("dcd_mapping.resource_utils.time.sleep") ) - with pytest.raises(requests.ConnectionError): + with pytest.raises(httpx.ConnectError): request_with_backoff( "http://example.com/resource", max_retries=5, backoff_factor=0.1 ) @@ -90,7 +92,7 @@ def test_5xx_then_success_retries(): with ExitStack() as stack: get_mock = stack.enter_context( mock.patch( - "dcd_mapping.resource_utils.requests.get", + "dcd_mapping.resource_utils.httpx.get", side_effect=_sequence_side_effect(seq), ) ) @@ -108,14 +110,14 @@ def test_5xx_until_max_raises_http_error(): with ExitStack() as stack: stack.enter_context( mock.patch( - "dcd_mapping.resource_utils.requests.get", + "dcd_mapping.resource_utils.httpx.get", side_effect=_sequence_side_effect(seq), ) ) sleep_mock = stack.enter_context( mock.patch("dcd_mapping.resource_utils.time.sleep") ) - with pytest.raises(requests.HTTPError): + with pytest.raises(httpx.HTTPStatusError): request_with_backoff( "http://example.com/resource", max_retries=3, backoff_factor=0.1 ) @@ -129,7 +131,7 @@ def test_429_with_retry_after_header_respected(): with ExitStack() as stack: get_mock = stack.enter_context( mock.patch( - "dcd_mapping.resource_utils.requests.get", + "dcd_mapping.resource_utils.httpx.get", side_effect=_sequence_side_effect([resp1, resp2]), ) ) @@ -148,7 +150,7 @@ def test_429_with_bad_retry_after_falls_back_to_backoff(): with ExitStack() as stack: stack.enter_context( mock.patch( - "dcd_mapping.resource_utils.requests.get", + "dcd_mapping.resource_utils.httpx.get", side_effect=_sequence_side_effect([resp1, resp2]), ) ) @@ -164,12 +166,12 @@ def test_non_retryable_4xx_raises_immediately(): resp = _DummyResponse(404) with ExitStack() as stack: stack.enter_context( - mock.patch("dcd_mapping.resource_utils.requests.get", return_value=resp) + mock.patch("dcd_mapping.resource_utils.httpx.get", return_value=resp) ) sleep_mock = stack.enter_context( mock.patch("dcd_mapping.resource_utils.time.sleep") ) - with pytest.raises(requests.HTTPError): + with pytest.raises(httpx.HTTPStatusError): request_with_backoff("http://example.com/resource") # no sleeps for non-retryable errors sleep_mock.assert_not_called() @@ -179,7 +181,7 @@ def test_exhausted_retries_without_response_raises_request_exception(): # The only way to trigger the terminal state in the function is to not even # attempt a request (max_retries=0) with mock.patch( - "dcd_mapping.resource_utils.requests.get", return_value=_DummyResponse(500) + "dcd_mapping.resource_utils.httpx.get", return_value=_DummyResponse(500) ), mock.patch("dcd_mapping.resource_utils.time.sleep"), pytest.raises( Exception # noqa: PT011 ) as exc: @@ -190,7 +192,7 @@ def test_exhausted_retries_without_response_raises_request_exception(): def test_kwargs_are_passed_through_to_requests_get(): dummy = _DummyResponse(200) with mock.patch( - "dcd_mapping.resource_utils.requests.get", return_value=dummy + "dcd_mapping.resource_utils.httpx.get", return_value=dummy ) as get_mock: request_with_backoff( "http://example.com/resource", headers={"X-Test": "1"}, params={"q": "x"} From 60d2a1b87c74b29bfdead22dfbce3670966893ca Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 2 Mar 2026 13:11:13 -0800 Subject: [PATCH 4/4] chore: bump version to 2026.1.1 --- src/dcd_mapping/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dcd_mapping/version.py b/src/dcd_mapping/version.py index e6693f5..61f5468 100644 --- a/src/dcd_mapping/version.py +++ b/src/dcd_mapping/version.py @@ -1,3 +1,3 @@ """Provide dcd mapping version""" -dcd_mapping_version = "2026.1.0" +dcd_mapping_version = "2026.1.1"