From f55e36c0d945d3718fbf31721de078a10de2268e Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 9 Feb 2026 12:17:41 -0800
Subject: [PATCH 01/57] Ignore more venv names
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index f634e823..f001284b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ htmlcov
# common virtual environment names
venv*
env
+.venv
# editors
.idea
From 94eba4b6c9e208cc5970e83ad23c7ca1c2dcb2b4 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 9 Feb 2026 12:22:52 -0800
Subject: [PATCH 02/57] Replace custom cachine with requests cache library
---
pori_python/graphkb/match.py | 16 ------
pori_python/graphkb/util.py | 81 +++++++++++++++++++---------
tests/test_graphkb/test_statement.py | 2 +-
3 files changed, 56 insertions(+), 43 deletions(-)
diff --git a/pori_python/graphkb/match.py b/pori_python/graphkb/match.py
index 0c791383..d24bb06e 100644
--- a/pori_python/graphkb/match.py
+++ b/pori_python/graphkb/match.py
@@ -111,22 +111,6 @@ def get_equivalent_features(
)
-def cache_missing_features(conn: GraphKBConnection) -> None:
- """
- Create a cache of features that exist to avoid repeatedly querying
- for missing features
- """
- genes = cast(
- List[Ontology],
- conn.query({"target": "Feature", "returnProperties": ["name", "sourceId"], "neighbors": 0}),
- )
- for gene in genes:
- if gene["name"]:
- FEATURES_CACHE.add(gene["name"].lower())
- if gene["sourceId"]:
- FEATURES_CACHE.add(gene["sourceId"].lower())
-
-
def match_category_variant(
conn: GraphKBConnection,
reference_name: str,
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 85bfa517..84ab0a6b 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -8,6 +8,7 @@
import re
import time
from datetime import datetime
+from requests_cache import CacheMixin
from typing import Any, Dict, Iterable, List, Optional, Union, cast
from urllib3.util.retry import Retry
from urllib.parse import urlsplit
@@ -88,11 +89,8 @@ def millis_interval(start: datetime, end: datetime) -> int:
return millis
-def cache_key(request_body) -> str:
- """Create a cache key for a query request to GraphKB."""
- body = json.dumps(request_body, sort_keys=True)
- hash_code = hashlib.md5(f"/query{body}".encode("utf-8")).hexdigest()
- return hash_code
+class CustomSession(CacheMixin, requests.Session):
+ pass
class GraphKBConnection:
@@ -102,8 +100,32 @@ def __init__(
username: str = "",
password: str = "",
use_global_cache: bool = True,
+ cache_name: str = "",
):
- self.http = requests.Session()
+ """
+ Docstring for __init__
+
+ Args:
+ - use_global_cache: cache requests across all requests to GKB
+ - cache_db: Path or connection URL to the database which stors the requests cache. see https://requests-cache.readthedocs.io/en/v0.6.4/user_guide.html#cache-name
+ """
+ if use_global_cache:
+ if not cache_name:
+ self.http = CustomSession(
+ backend="memory",
+ cache_control=True,
+ allowable_methods=["GET", "POST"],
+ ignored_parameters=["Authorization"],
+ )
+ else:
+ self.http = CustomSession(
+ cache_name,
+ cache_control=True,
+ allowable_methods=["GET", "POST"],
+ ignored_parameters=["Authorization"],
+ )
+ else:
+ self.http = requests.Session()
retries = Retry(
total=100,
connect=5,
@@ -117,8 +139,10 @@ def __init__(
self.url = url
self.username = username
self.password = password
- self.headers = {"Accept": "application/json", "Content-Type": "application/json"}
- self.cache: Dict[Any, Any] = {} if not use_global_cache else QUERY_CACHE
+ self.headers = {
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
self.request_count = 0
self.first_request: Optional[datetime] = None
self.last_request: Optional[datetime] = None
@@ -137,7 +161,13 @@ def load(self) -> Optional[float]:
return self.request_count * 1000 / msec
return None
- def request(self, endpoint: str, method: str = "GET", **kwargs) -> Dict:
+ def request(
+ self,
+ endpoint: str,
+ method: str = "GET",
+ headers: Optional[dict[str, str]] = None,
+ **kwargs,
+ ) -> Dict:
"""Request wrapper to handle adding common headers and logging.
Args:
@@ -158,6 +188,11 @@ def request(self, endpoint: str, method: str = "GET", **kwargs) -> Dict:
if endpoint in ["query", "parse"]:
timeout = (connect_timeout, read_timeout)
+ request_headers = {}
+ request_headers.update(self.headers)
+ if headers is not None:
+ request_headers.update(headers)
+
start_time = datetime.now()
if not self.first_request:
@@ -180,8 +215,8 @@ def request(self, endpoint: str, method: str = "GET", **kwargs) -> Dict:
need_refresh_login = False
self.request_count += 1
- resp = requests.request(
- method, url, headers=self.headers, timeout=timeout, **kwargs
+ resp = self.http.request(
+ method, url, headers=request_headers, timeout=timeout, **kwargs
)
if resp.status_code == 401 or resp.status_code == 403:
logger.debug(f"/{endpoint} - {resp.status_code} - retrying")
@@ -294,11 +329,6 @@ def login(self, username: str, password: str, pori_demo: bool = False) -> None:
def refresh_login(self) -> None:
self.login(self.username, self.password)
- def set_cache_data(self, request_body: Dict, result: List[Record]) -> None:
- """Explicitly add a query to the cache."""
- hash_code = cache_key(request_body)
- self.cache[hash_code] = result
-
def query(
self,
request_body: Dict = {},
@@ -310,23 +340,22 @@ def query(
"""
Query GraphKB
"""
- result: List[Record] = []
- hash_code = ""
-
- if not ignore_cache and paginate:
- hash_code = cache_key(request_body)
- if hash_code in self.cache and not force_refresh:
- return self.cache[hash_code]
+ headers = {}
+ if ignore_cache or force_refresh:
+ headers = {"Cache-Control": "no-cache"}
+ result: List[Record] = []
while True:
- content = self.post("query", data={**request_body, "limit": limit, "skip": len(result)})
+ content = self.post(
+ "query",
+ data={**request_body, "limit": limit, "skip": len(result)},
+ headers=headers,
+ )
records = content["result"]
result.extend(records)
if len(records) < limit or not paginate:
break
- if not ignore_cache and paginate:
- self.cache[hash_code] = result
return result
def parse(self, hgvs_string: str, requireFeatures: bool = False) -> ParsedVariant:
diff --git a/tests/test_graphkb/test_statement.py b/tests/test_graphkb/test_statement.py
index fcf0bef5..49b15a27 100644
--- a/tests/test_graphkb/test_statement.py
+++ b/tests/test_graphkb/test_statement.py
@@ -41,7 +41,7 @@ def term_tree_calls(*final_values):
query_mock = Mock()
query_mock.side_effect = return_values
- return Mock(query=query_mock, cache={})
+ return Mock(query=query_mock)
class TestCategorizeRelevance:
From 21a224bdde59e022a8ee409c214e84c0e51b5b69 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 9 Feb 2026 12:36:53 -0800
Subject: [PATCH 03/57] Add requests cache pkg to requirements
---
setup.cfg | 1 +
1 file changed, 1 insertion(+)
diff --git a/setup.cfg b/setup.cfg
index b5d3df06..a22e99ce 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -37,6 +37,7 @@ install_requires =
requests
tqdm
typing_extensions>=3.7.4.2,<5
+ requests-cache[sqlite]
[options.extras_require]
deploy = twine; wheel; m2r
From c327f6fafbf80a50a5b145a839efdb477280e8bf Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 9 Feb 2026 12:50:30 -0800
Subject: [PATCH 04/57] Remove leftover references to old cache
---
pori_python/graphkb/vocab.py | 5 -----
1 file changed, 5 deletions(-)
diff --git a/pori_python/graphkb/vocab.py b/pori_python/graphkb/vocab.py
index 26033e75..3242a06c 100644
--- a/pori_python/graphkb/vocab.py
+++ b/pori_python/graphkb/vocab.py
@@ -181,9 +181,6 @@ def get_terms_set(
) -> Set[str]:
"""Get a set of vocabulary rids given some base/parent term names."""
base_terms = [base_terms] if isinstance(base_terms, str) else base_terms
- cache_key = tuple(sorted(base_terms))
- if graphkb_conn.cache.get(cache_key, None) and not ignore_cache:
- return graphkb_conn.cache[cache_key]
terms = set()
for base_term in base_terms:
terms.update(
@@ -193,6 +190,4 @@ def get_terms_set(
)
)
)
- if not ignore_cache:
- graphkb_conn.cache[cache_key] = terms
return terms
From d7db900468bdf09e84902fad551298747b81f93c Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 9 Feb 2026 13:07:04 -0800
Subject: [PATCH 05/57] Remove no longer relevant test
---
tests/test_graphkb/test_match.py | 16 ----------------
1 file changed, 16 deletions(-)
diff --git a/tests/test_graphkb/test_match.py b/tests/test_graphkb/test_match.py
index d4ee5679..00798143 100644
--- a/tests/test_graphkb/test_match.py
+++ b/tests/test_graphkb/test_match.py
@@ -563,22 +563,6 @@ def test_structural_variants(self, conn):
assert type not in expected.get("does_not_matches", {}).get("type", [])
-class TestCacheMissingFeatures:
- def test_filling_cache(self):
- mock_conn = MagicMock(
- query=MagicMock(
- return_value=[
- {"name": "bob", "sourceId": "alice"},
- {"name": "KRAS", "sourceId": "1234"},
- ]
- )
- )
- match.cache_missing_features(mock_conn)
- assert "kras" in match.FEATURES_CACHE
- assert "alice" in match.FEATURES_CACHE
- match.FEATURES_CACHE = None
-
-
class TestTypeScreening:
# Types as class variables
default_type = DEFAULT_NON_STRUCTURAL_VARIANT_TYPE
From 5f80d1ae25aeb87b81b055bff5b162e6def48d63 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Wed, 11 Feb 2026 21:30:46 -0800
Subject: [PATCH 06/57] Fix missing merge conflict
---
pori_python/graphkb/util.py | 48 ++++++++++---------------------------
1 file changed, 12 insertions(+), 36 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 2c1e7e00..a0001347 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -100,7 +100,7 @@ def __init__(
username: str = '',
password: str = '',
use_global_cache: bool = True,
- cache_name: str = "",
+ cache_name: str = '',
):
"""
Docstring for __init__
@@ -112,17 +112,17 @@ def __init__(
if use_global_cache:
if not cache_name:
self.http = CustomSession(
- backend="memory",
+ backend='memory',
cache_control=True,
- allowable_methods=["GET", "POST"],
- ignored_parameters=["Authorization"],
+ allowable_methods=['GET', 'POST'],
+ ignored_parameters=['Authorization'],
)
else:
self.http = CustomSession(
cache_name,
cache_control=True,
- allowable_methods=["GET", "POST"],
- ignored_parameters=["Authorization"],
+ allowable_methods=['GET', 'POST'],
+ ignored_parameters=['Authorization'],
)
else:
self.http = requests.Session()
@@ -139,15 +139,10 @@ def __init__(
self.url = url
self.username = username
self.password = password
-<<<<<<< HEAD
self.headers = {
- "Accept": "application/json",
- "Content-Type": "application/json",
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
}
-=======
- self.headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
- self.cache: Dict[Any, Any] = {} if not use_global_cache else QUERY_CACHE
->>>>>>> develop
self.request_count = 0
self.first_request: Optional[datetime] = None
self.last_request: Optional[datetime] = None
@@ -166,17 +161,13 @@ def load(self) -> Optional[float]:
return self.request_count * 1000 / msec
return None
-<<<<<<< HEAD
def request(
self,
endpoint: str,
- method: str = "GET",
+ method: str = 'GET',
headers: Optional[dict[str, str]] = None,
**kwargs,
) -> Dict:
-=======
- def request(self, endpoint: str, method: str = 'GET', **kwargs) -> Dict:
->>>>>>> develop
"""Request wrapper to handle adding common headers and logging.
Args:
@@ -348,33 +339,18 @@ def query(
"""
Query GraphKB
"""
-<<<<<<< HEAD
headers = {}
if ignore_cache or force_refresh:
- headers = {"Cache-Control": "no-cache"}
-=======
- result: List[Record] = []
- hash_code = ''
-
- if not ignore_cache and paginate:
- hash_code = cache_key(request_body)
- if hash_code in self.cache and not force_refresh:
- return self.cache[hash_code]
->>>>>>> develop
+ headers = {'Cache-Control': 'no-cache'}
result: List[Record] = []
while True:
-<<<<<<< HEAD
content = self.post(
- "query",
- data={**request_body, "limit": limit, "skip": len(result)},
+ 'query',
+ data={**request_body, 'limit': limit, 'skip': len(result)},
headers=headers,
)
- records = content["result"]
-=======
- content = self.post('query', data={**request_body, 'limit': limit, 'skip': len(result)})
records = content['result']
->>>>>>> develop
result.extend(records)
if len(records) < limit or not paginate:
break
From df2d899e6380d7568570734bfaa1fe8551f1a226 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Wed, 11 Feb 2026 23:10:58 -0800
Subject: [PATCH 07/57] fix: due to change in caching, the mock now needs to be
re setup between calls so I split the tests into two functions
---
pori_python/graphkb/util.py | 2 --
tests/test_graphkb/test_statement.py | 15 ++++++++++++---
2 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index a0001347..ba54a07a 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -17,8 +17,6 @@
from .constants import DEFAULT_LIMIT, TYPES_TO_NOTATION, AA_3to1_MAPPING
-QUERY_CACHE: Dict[Any, Any] = {}
-
# name the logger after the package to make it simple to disable for packages using this one as a dependency
# https://stackoverflow.com/questions/11029717/how-do-i-disable-log-messages-from-the-requests-library
diff --git a/tests/test_graphkb/test_statement.py b/tests/test_graphkb/test_statement.py
index 0b4ccd22..00720e90 100644
--- a/tests/test_graphkb/test_statement.py
+++ b/tests/test_graphkb/test_statement.py
@@ -17,11 +17,20 @@ def conn() -> GraphKBConnection:
@pytest.fixture()
def graphkb_conn():
+ """
+ Mocks the query functionality required by the calls made for the categorize_relevance function
+
+ categorize_relevance calls query twice for each term in the values of the category_base_terms object
+
+ - get_terms_set ([term, term, term...])
+ - get_term_tree(term)
+ - query(query(term))
+ """
def make_rid_list(*values):
return [{'@rid': v} for v in values]
def term_tree_calls(*final_values):
- # this function makes 2 calls to conn.query here
+ # this function makes 2 calls to conn.query here b/c the get_terms_set function will always call query twice
sets = [['fake'], final_values]
return [make_rid_list(*s) for s in sets]
@@ -77,18 +86,18 @@ def test_no_match(self, graphkb_conn):
category = statement.categorize_relevance(graphkb_conn, 'x')
assert category == ''
- def test_custom_categories(self, graphkb_conn):
+ def test_custom_categories_not_found(self, graphkb_conn):
category = statement.categorize_relevance(
graphkb_conn, 'x', [('blargh', ['some', 'blargh'])]
)
assert category == ''
+ def test_custom_categories_match(self, graphkb_conn):
category = statement.categorize_relevance(
graphkb_conn, '1', [('blargh', ['some', 'blargh'])]
)
assert category == 'blargh'
-
@pytest.mark.skipif(
EXCLUDE_BCGSC_TESTS, reason='db-specific rid; requires Inferred Functional Annotation source'
)
From 1107f68499787a236ec25afbddde306f111e0106 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Wed, 11 Feb 2026 23:11:54 -0800
Subject: [PATCH 08/57] format with ruff
---
tests/test_graphkb/test_statement.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/test_graphkb/test_statement.py b/tests/test_graphkb/test_statement.py
index 00720e90..550191ed 100644
--- a/tests/test_graphkb/test_statement.py
+++ b/tests/test_graphkb/test_statement.py
@@ -26,6 +26,7 @@ def graphkb_conn():
- get_term_tree(term)
- query(query(term))
"""
+
def make_rid_list(*values):
return [{'@rid': v} for v in values]
@@ -98,6 +99,7 @@ def test_custom_categories_match(self, graphkb_conn):
)
assert category == 'blargh'
+
@pytest.mark.skipif(
EXCLUDE_BCGSC_TESTS, reason='db-specific rid; requires Inferred Functional Annotation source'
)
From 5e60ddc5c52c9991aa369aae0748b624c7a41382 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Wed, 11 Feb 2026 23:29:59 -0800
Subject: [PATCH 09/57] Add pass-through args for sessions
---
pori_python/graphkb/util.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index ba54a07a..8cfe79d4 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -99,6 +99,7 @@ def __init__(
password: str = '',
use_global_cache: bool = True,
cache_name: str = '',
+ **session_kwargs
):
"""
Docstring for __init__
@@ -114,6 +115,7 @@ def __init__(
cache_control=True,
allowable_methods=['GET', 'POST'],
ignored_parameters=['Authorization'],
+ **session_kwargs
)
else:
self.http = CustomSession(
@@ -121,9 +123,10 @@ def __init__(
cache_control=True,
allowable_methods=['GET', 'POST'],
ignored_parameters=['Authorization'],
+ **session_kwargs
)
else:
- self.http = requests.Session()
+ self.http = requests.Session(**session_kwargs)
retries = Retry(
total=100,
connect=5,
From ae37e3bfee34cc97e9167fe187508d53e5fb07d2 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Wed, 11 Feb 2026 23:31:29 -0800
Subject: [PATCH 10/57] fix arg in docstring to match arg name
---
pori_python/graphkb/util.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 8cfe79d4..60ce6992 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -106,7 +106,7 @@ def __init__(
Args:
- use_global_cache: cache requests across all requests to GKB
- - cache_db: Path or connection URL to the database which stors the requests cache. see https://requests-cache.readthedocs.io/en/v0.6.4/user_guide.html#cache-name
+ - cache_name: Path or connection URL to the database which stors the requests cache. see https://requests-cache.readthedocs.io/en/v0.6.4/user_guide.html#cache-name
"""
if use_global_cache:
if not cache_name:
From 135df0390be00de7cc5ebe634a3350ca180947a1 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Wed, 11 Feb 2026 23:32:54 -0800
Subject: [PATCH 11/57] format with ruff
---
pori_python/graphkb/util.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 60ce6992..382dba63 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -99,7 +99,7 @@ def __init__(
password: str = '',
use_global_cache: bool = True,
cache_name: str = '',
- **session_kwargs
+ **session_kwargs,
):
"""
Docstring for __init__
@@ -115,7 +115,7 @@ def __init__(
cache_control=True,
allowable_methods=['GET', 'POST'],
ignored_parameters=['Authorization'],
- **session_kwargs
+ **session_kwargs,
)
else:
self.http = CustomSession(
@@ -123,7 +123,7 @@ def __init__(
cache_control=True,
allowable_methods=['GET', 'POST'],
ignored_parameters=['Authorization'],
- **session_kwargs
+ **session_kwargs,
)
else:
self.http = requests.Session(**session_kwargs)
From 02d5034216ba9e1b13913861a07984995494f438 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Fri, 1 May 2026 15:38:02 -0700
Subject: [PATCH 12/57] Include rate limiting by default
---
pori_python/graphkb/util.py | 111 ++++++++++++++++++++++++------------
setup.cfg | 7 +++
2 files changed, 82 insertions(+), 36 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 382dba63..f9705af4 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -8,7 +8,9 @@
import re
import time
from datetime import datetime
+from pyrate_limiter import Duration, Limiter, Rate
from requests_cache import CacheMixin
+from requests_ratelimiter import LimiterMixin
from typing import Any, Dict, Iterable, List, Optional, Union, cast
from urllib3.util.retry import Retry
from urllib.parse import urlsplit
@@ -20,7 +22,8 @@
# name the logger after the package to make it simple to disable for packages using this one as a dependency
# https://stackoverflow.com/questions/11029717/how-do-i-disable-log-messages-from-the-requests-library
-logger = logging.getLogger('graphkb')
+logger = logging.getLogger("graphkb")
+LIMITER = Limiter(Rate(3, Duration.SECOND))
def convert_to_rid_list(records: Iterable[Record]) -> List[str]:
@@ -65,7 +68,7 @@ def convert_aa_3to1(three_letter_notation: str) -> str:
last_match_end = match.end()
result.append(three_letter_notation[last_match_end:])
- return ''.join(result)
+ return "".join(result)
def join_url(base_url: str, *parts) -> str:
@@ -87,18 +90,25 @@ def millis_interval(start: datetime, end: datetime) -> int:
return millis
-class CustomSession(CacheMixin, requests.Session):
+class CachedSession(CacheMixin, requests.Session):
+ pass
+
+
+class CachedLimiterSession(LimiterMixin, CachedSession):
pass
class GraphKBConnection:
def __init__(
self,
- url: str = os.environ.get('GRAPHKB_URL'),
- username: str = '',
- password: str = '',
+ url: str = os.environ.get("GRAPHKB_URL"),
+ username: str = "",
+ password: str = "",
use_global_cache: bool = True,
- cache_name: str = '',
+ cache_name: str = "",
+ only_if_cached: bool = False,
+ session: Optional[requests.Session] = None,
+ limiter: Limiter = LIMITER,
**session_kwargs,
):
"""
@@ -107,26 +117,48 @@ def __init__(
Args:
- use_global_cache: cache requests across all requests to GKB
- cache_name: Path or connection URL to the database which stors the requests cache. see https://requests-cache.readthedocs.io/en/v0.6.4/user_guide.html#cache-name
+ - only_if_cached: this will set the cache-control header for all requests to only-if-cached which will raise 504 errors if a request does not exist in the cache already rather than making a new network request
"""
+ session_cls = requests.Session
+ if limiter and not use_global_cache:
+ raise NotImplementedError(
+ f"currently rate limiting by default also implements caching"
+ )
+ if session is not None:
+ if limiter is not None:
+ raise NotImplementedError("cannot add limiter to an existing session")
+ if use_global_cache:
+ raise NotImplementedError(
+ "the use_global_cache parameter should not be used with a custom session"
+ )
+ if cache_name:
+ raise NotImplementedError(
+ "cache_name should not be used with a custom input session"
+ )
+ if not use_global_cache and cache_name:
+ raise NotImplementedError(
+ "cache_name only applies when use_global_cache is True"
+ )
+
if use_global_cache:
if not cache_name:
- self.http = CustomSession(
- backend='memory',
- cache_control=True,
- allowable_methods=['GET', 'POST'],
- ignored_parameters=['Authorization'],
- **session_kwargs,
- )
+ session_kwargs["backend"] = "memory"
else:
- self.http = CustomSession(
- cache_name,
- cache_control=True,
- allowable_methods=['GET', 'POST'],
- ignored_parameters=['Authorization'],
- **session_kwargs,
- )
+ session_kwargs["cache_name"] = cache_name
+ session_kwargs["allowable_methods"] = ["GET", "POST"]
+ session_kwargs["ignored_parameters"] = ["Authorization"]
+ session_kwargs["cache_control"] = True
+ session_cls = CachedSession
+
+ if "PYTEST_CURRENT_TEST" not in os.environ:
+ if limiter:
+ session_kwargs["limiter"] = limiter
+ session_cls = CachedLimiterSession
+
+ if not session:
+ self.http = session_cls(**session_kwargs)
else:
- self.http = requests.Session(**session_kwargs)
+ self.http = session
retries = Retry(
total=100,
connect=5,
@@ -134,15 +166,16 @@ def __init__(
backoff_factor=5,
status_forcelist=[429, 500, 502, 503, 504],
)
- self.http.mount('https://', HTTPAdapter(max_retries=retries))
- self.token = ''
- self.token_kc = ''
+ self.only_if_cached = only_if_cached
+ self.http.mount("https://", HTTPAdapter(max_retries=retries))
+ self.token = ""
+ self.token_kc = ""
self.url = url
self.username = username
self.password = password
self.headers = {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
+ "Accept": "application/json",
+ "Content-Type": "application/json",
}
self.request_count = 0
self.first_request: Optional[datetime] = None
@@ -165,8 +198,11 @@ def load(self) -> Optional[float]:
def request(
self,
endpoint: str,
- method: str = 'GET',
+ method: str = "GET",
headers: Optional[dict[str, str]] = None,
+ ignore_cache=False,
+ force_refresh=False,
+ only_if_cached=False,
**kwargs,
) -> Dict:
"""Request wrapper to handle adding common headers and logging.
@@ -178,6 +214,13 @@ def request(
Returns:
dict: the json response as a python dict
"""
+ if headers is None:
+ headers = {}
+
+ if ignore_cache or force_refresh:
+ headers["Cache-Control"] = "no-cache"
+ elif only_if_cached or self.only_if_cached:
+ headers["Cache-Control"] = "only-if-cached"
url = join_url(self.url, endpoint)
self.request_count += 1
connect_timeout = 7
@@ -333,23 +376,19 @@ def query(
self,
request_body: Dict = {},
paginate: bool = True,
- ignore_cache: bool = False,
- force_refresh: bool = False,
limit: int = DEFAULT_LIMIT,
+ **kwargs,
) -> List[Record]:
"""
Query GraphKB
"""
- headers = {}
- if ignore_cache or force_refresh:
- headers = {'Cache-Control': 'no-cache'}
result: List[Record] = []
while True:
content = self.post(
- 'query',
- data={**request_body, 'limit': limit, 'skip': len(result)},
- headers=headers,
+ "query",
+ data={**request_body, "limit": limit, "skip": len(result)},
+ **kwargs,
)
records = content['result']
result.extend(records)
diff --git a/setup.cfg b/setup.cfg
index 3f2c365b..c4ec7157 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -37,6 +37,8 @@ install_requires =
tqdm
typing_extensions>=3.7.4.2,<5
requests-cache[sqlite]
+ requests-ratelimiter
+ pyrate-limiter
[options.extras_require]
deploy = twine; wheel; m2r
@@ -63,3 +65,8 @@ pori_python = py.typed
[options.entry_points]
console_scripts =
ipr = pori_python.ipr.main:command_interface
+
+
+[tool:pytest]
+log_cli = true
+log_cli_level = INFO
From 93c1b481cbd92e23e30fb0e17271ae65937f7be7 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Fri, 1 May 2026 15:45:04 -0700
Subject: [PATCH 13/57] Format with ruff
---
pori_python/graphkb/util.py | 64 ++++++++++++++++++-------------------
pori_python/ipr/main.py | 2 +-
tests/test_ipr/test_main.py | 1 -
3 files changed, 32 insertions(+), 35 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index f9705af4..cf26e070 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -22,7 +22,7 @@
# name the logger after the package to make it simple to disable for packages using this one as a dependency
# https://stackoverflow.com/questions/11029717/how-do-i-disable-log-messages-from-the-requests-library
-logger = logging.getLogger("graphkb")
+logger = logging.getLogger('graphkb')
LIMITER = Limiter(Rate(3, Duration.SECOND))
@@ -68,7 +68,7 @@ def convert_aa_3to1(three_letter_notation: str) -> str:
last_match_end = match.end()
result.append(three_letter_notation[last_match_end:])
- return "".join(result)
+ return ''.join(result)
def join_url(base_url: str, *parts) -> str:
@@ -101,11 +101,11 @@ class CachedLimiterSession(LimiterMixin, CachedSession):
class GraphKBConnection:
def __init__(
self,
- url: str = os.environ.get("GRAPHKB_URL"),
- username: str = "",
- password: str = "",
+ url: str = os.environ.get('GRAPHKB_URL'),
+ username: str = '',
+ password: str = '',
use_global_cache: bool = True,
- cache_name: str = "",
+ cache_name: str = '',
only_if_cached: bool = False,
session: Optional[requests.Session] = None,
limiter: Limiter = LIMITER,
@@ -121,38 +121,34 @@ def __init__(
"""
session_cls = requests.Session
if limiter and not use_global_cache:
- raise NotImplementedError(
- f"currently rate limiting by default also implements caching"
- )
+ raise NotImplementedError(f'currently rate limiting by default also implements caching')
if session is not None:
if limiter is not None:
- raise NotImplementedError("cannot add limiter to an existing session")
+ raise NotImplementedError('cannot add limiter to an existing session')
if use_global_cache:
raise NotImplementedError(
- "the use_global_cache parameter should not be used with a custom session"
+ 'the use_global_cache parameter should not be used with a custom session'
)
if cache_name:
raise NotImplementedError(
- "cache_name should not be used with a custom input session"
+ 'cache_name should not be used with a custom input session'
)
if not use_global_cache and cache_name:
- raise NotImplementedError(
- "cache_name only applies when use_global_cache is True"
- )
+ raise NotImplementedError('cache_name only applies when use_global_cache is True')
if use_global_cache:
if not cache_name:
- session_kwargs["backend"] = "memory"
+ session_kwargs['backend'] = 'memory'
else:
- session_kwargs["cache_name"] = cache_name
- session_kwargs["allowable_methods"] = ["GET", "POST"]
- session_kwargs["ignored_parameters"] = ["Authorization"]
- session_kwargs["cache_control"] = True
+ session_kwargs['cache_name'] = cache_name
+ session_kwargs['allowable_methods'] = ['GET', 'POST']
+ session_kwargs['ignored_parameters'] = ['Authorization']
+ session_kwargs['cache_control'] = True
session_cls = CachedSession
- if "PYTEST_CURRENT_TEST" not in os.environ:
+ if 'PYTEST_CURRENT_TEST' not in os.environ:
if limiter:
- session_kwargs["limiter"] = limiter
+ session_kwargs['limiter'] = limiter
session_cls = CachedLimiterSession
if not session:
@@ -167,15 +163,15 @@ def __init__(
status_forcelist=[429, 500, 502, 503, 504],
)
self.only_if_cached = only_if_cached
- self.http.mount("https://", HTTPAdapter(max_retries=retries))
- self.token = ""
- self.token_kc = ""
+ self.http.mount('https://', HTTPAdapter(max_retries=retries))
+ self.token = ''
+ self.token_kc = ''
self.url = url
self.username = username
self.password = password
self.headers = {
- "Accept": "application/json",
- "Content-Type": "application/json",
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
}
self.request_count = 0
self.first_request: Optional[datetime] = None
@@ -198,7 +194,7 @@ def load(self) -> Optional[float]:
def request(
self,
endpoint: str,
- method: str = "GET",
+ method: str = 'GET',
headers: Optional[dict[str, str]] = None,
ignore_cache=False,
force_refresh=False,
@@ -218,9 +214,9 @@ def request(
headers = {}
if ignore_cache or force_refresh:
- headers["Cache-Control"] = "no-cache"
+ headers['Cache-Control'] = 'no-cache'
elif only_if_cached or self.only_if_cached:
- headers["Cache-Control"] = "only-if-cached"
+ headers['Cache-Control'] = 'only-if-cached'
url = join_url(self.url, endpoint)
self.request_count += 1
connect_timeout = 7
@@ -386,8 +382,8 @@ def query(
result: List[Record] = []
while True:
content = self.post(
- "query",
- data={**request_body, "limit": limit, "skip": len(result)},
+ 'query',
+ data={**request_body, 'limit': limit, 'skip': len(result)},
**kwargs,
)
records = content['result']
@@ -501,7 +497,9 @@ def stripDisplayName(displayName: str, withRef: bool = True, withRefSeq: bool =
def stringifyVariant(
- variant: Union[PositionalVariant, ParsedVariant], withRef: bool = True, withRefSeq: bool = True
+ variant: Union[PositionalVariant, ParsedVariant],
+ withRef: bool = True,
+ withRefSeq: bool = True,
) -> str:
"""
Convert variant record to a string representation (displayName/hgvs)
diff --git a/pori_python/ipr/main.py b/pori_python/ipr/main.py
index cbb7c128..20098b7f 100644
--- a/pori_python/ipr/main.py
+++ b/pori_python/ipr/main.py
@@ -31,8 +31,8 @@
preprocess_cosmic,
preprocess_expression_variants,
preprocess_hla,
- preprocess_msi,
preprocess_hrd,
+ preprocess_msi,
preprocess_signature_variants,
preprocess_small_mutations,
preprocess_structural_variants,
diff --git a/tests/test_ipr/test_main.py b/tests/test_ipr/test_main.py
index 8fe585cd..cd161e18 100644
--- a/tests/test_ipr/test_main.py
+++ b/tests/test_ipr/test_main.py
@@ -122,7 +122,6 @@ def side_effect_function(*args, **kwargs):
return report_content
-@pytest.mark.skip(reason='KBDEV-1308; taking too long, getting canceled after reaching max delay')
@pytest.mark.skipif(EXCLUDE_INTEGRATION_TESTS, reason='excluding long running integration tests')
class TestCreateReport:
def test_main_sections_present(self, report_upload_content: Dict) -> None:
From bc15dfc90651bc5bd9bdef8fa24611a36b25e569 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Fri, 1 May 2026 15:48:31 -0700
Subject: [PATCH 14/57] fallback import for older versions of pyrate-limiter
---
pori_python/graphkb/util.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index cf26e070..f4132fa9 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -8,7 +8,13 @@
import re
import time
from datetime import datetime
-from pyrate_limiter import Duration, Limiter, Rate
+from pyrate_limiter import Duration, Limiter
+
+try:
+ from pyrate_limiter import Rate
+except ImportError:
+ from pyrate_limiter import RequestRate as Rate
+
from requests_cache import CacheMixin
from requests_ratelimiter import LimiterMixin
from typing import Any, Dict, Iterable, List, Optional, Union, cast
From 03994d9251f0f2cbe5298fb6ba8f13b858071fb2 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Fri, 1 May 2026 16:04:40 -0700
Subject: [PATCH 15/57] only run integration tests on 3.11 and report coverage
for 3.11
---
.github/workflows/pytest.yml | 20 +++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 7804a8a0..27a0d28c 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -32,6 +32,18 @@ jobs:
run: |
pip install ruff
ruff format --check pori_python tests
+ - name: short tests
+ run: |
+ pip list
+ pytest --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov pori_python --cov-report term --cov-report xml
+ env:
+ IPR_USER: ${{ secrets.IPR_TEST_USER }}
+ IPR_PASS: ${{ secrets.IPR_TEST_PASSWORD }}
+ GRAPHKB_USER: ${{ secrets.GKB_TEST_USER }}
+ GRAPHKB_PASS: ${{ secrets.GKB_TEST_PASS }}
+ GRAPHKB_URL: ${{ secrets.GKB_TEST_URL }}
+ EXCLUDE_INTEGRATION_TESTS: 1
+ if: matrix.python-version != 3.11
- name: Full Tests with pytest
run: |
pip list
@@ -42,16 +54,14 @@ jobs:
GRAPHKB_USER: ${{ secrets.GKB_TEST_USER }}
GRAPHKB_PASS: ${{ secrets.GKB_TEST_PASS }}
GRAPHKB_URL: ${{ secrets.GKB_TEST_URL }}
- # SDEV-3381 - Turn off integration tests temporarily, till efficiency is increased
- # turn on integration tests for one python version only
- EXCLUDE_INTEGRATION_TESTS: ${{ matrix.python-version != '3.11' }}
+ if: matrix.python-version == 3.11
- name: Upload pytest test results
uses: actions/upload-artifact@master
with:
name: pytest-results-${{ matrix.python-version }}
path: junit/test-results-${{ matrix.python-version }}.xml
# Use always() to always run this step to publish test results when there are test failures
- if: matrix.python-version == 3.9
+ if: matrix.python-version == 3.11
- name: Update code coverage report to CodeCov
uses: codecov/codecov-action@v3
with:
@@ -61,4 +71,4 @@ jobs:
env_vars: OS,PYTHON
name: codecov-umbrella
fail_ci_if_error: true
- if: matrix.python-version == 3.9
+ if: matrix.python-version == 3.11
From 1d05cdd87ed8716f282013b99b6267ebbb4c5a76 Mon Sep 17 00:00:00 2001
From: mathieulemieux
Date: Tue, 26 May 2026 11:23:14 -0700
Subject: [PATCH 16/57] Add to enum values to hrd kbCategory specs
---
pori_python/ipr/content.spec.json | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/pori_python/ipr/content.spec.json b/pori_python/ipr/content.spec.json
index c994ba6f..f74b807d 100644
--- a/pori_python/ipr/content.spec.json
+++ b/pori_python/ipr/content.spec.json
@@ -564,6 +564,10 @@
"hrd": {
"properties": {
"kbCategory": {
+ "enum": [
+ "homologous recombination deficiency strong signature",
+ "homologous recombination deficiency moderate signature"
+ ],
"type": "string"
},
"score": {
From 4107b2477e6913c87fe90ba8639250fe12553870 Mon Sep 17 00:00:00 2001
From: mathieulemieux
Date: Tue, 26 May 2026 11:26:09 -0700
Subject: [PATCH 17/57] Add hrd tests for annotate_signature_variants()
---
tests/test_ipr/test_annotate.py | 26 +++++++++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/tests/test_ipr/test_annotate.py b/tests/test_ipr/test_annotate.py
index 43de4216..79eb54c7 100644
--- a/tests/test_ipr/test_annotate.py
+++ b/tests/test_ipr/test_annotate.py
@@ -6,6 +6,7 @@
from pori_python.ipr.constants import (
COSMIC_SIGNATURE_VARIANT_TYPE,
HLA_SIGNATURE_VARIANT_TYPE,
+ HRD_MAPPING,
MSI_MAPPING,
TMB_SIGNATURE,
TMB_SIGNATURE_VARIANT_TYPE,
@@ -13,7 +14,7 @@
from pori_python.ipr.inputs import preprocess_signature_variants
from pori_python.types import IprSmallMutationVariant
-from .test_ipr import DISEASE_RIDS
+from .test_ipr import DISEASE_RIDS # dummy RIDs; will always result in matchedCancer == false
EXCLUDE_BCGSC_TESTS = os.environ.get('EXCLUDE_BCGSC_TESTS') == '1'
@@ -203,6 +204,29 @@ def test_annotate_signature_variants_msi(self, graphkb_conn):
)
assert len(msi) != 0
+ def test_annotate_signature_variants_hrd_strong(self, graphkb_conn):
+ """Test HRD strong signature"""
+ hrd = annotate_signature_variants(
+ graphkb_conn,
+ DISEASE_RIDS,
+ preprocess_signature_variants(
+ [HRD_MAPPING['homologous recombination deficiency strong signature']]
+ ),
+ )
+ assert len(hrd) != 0
+
+ @pytest.mark.skip(reason='no GKB statement for HRD moderate Signature CVs yet')
+ def test_annotate_signature_variants_hrd_moderate(self, graphkb_conn):
+ """Test HRD moderate signature"""
+ hrd = annotate_signature_variants(
+ graphkb_conn,
+ DISEASE_RIDS,
+ preprocess_signature_variants(
+ [HRD_MAPPING['homologous recombination deficiency moderate signature']]
+ ),
+ )
+ assert len(hrd) != 0
+
def test_annotate_structural_variants_tp53(self, graphkb_conn):
"""Verify alternate TP53 variants match."""
ref_key = 'prot_only'
From bb9c6aed837b973aa498b0c40785080c869bfa57 Mon Sep 17 00:00:00 2001
From: mathieulemieux
Date: Tue, 26 May 2026 13:06:35 -0700
Subject: [PATCH 18/57] Change enum values into examples for hrd kbCategory
specs
---
pori_python/ipr/content.spec.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pori_python/ipr/content.spec.json b/pori_python/ipr/content.spec.json
index f74b807d..f35b15bc 100644
--- a/pori_python/ipr/content.spec.json
+++ b/pori_python/ipr/content.spec.json
@@ -564,7 +564,7 @@
"hrd": {
"properties": {
"kbCategory": {
- "enum": [
+ "examples": [
"homologous recombination deficiency strong signature",
"homologous recombination deficiency moderate signature"
],
From 73e637119088fafbc468a462ae0d7fb446ee862b Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 27 Jul 2026 12:20:09 -0700
Subject: [PATCH 19/57] Limiter mixin must follow cache mixin or limit applies
to cached entries as well
---
pori_python/graphkb/util.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index f4132fa9..6d8fc962 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -100,7 +100,7 @@ class CachedSession(CacheMixin, requests.Session):
pass
-class CachedLimiterSession(LimiterMixin, CachedSession):
+class CachedLimiterSession(CacheMixin, LimiterMixin, requests.Session):
pass
From 90eec37b63a9c79977c5bddf4767a95e67ccf9a8 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Tue, 28 Jul 2026 14:03:37 -0700
Subject: [PATCH 20/57] Solves MRO error when limiter not applied
---
pori_python/graphkb/util.py | 15 ++++++---------
1 file changed, 6 insertions(+), 9 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index a0b6ee29..ddd2c45a 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -15,6 +15,7 @@
except ImportError:
from pyrate_limiter import RequestRate as Rate
+import requests_cache
from requests_cache import CacheMixin
from requests_ratelimiter import LimiterMixin
from typing import Any, Dict, Iterable, List, Optional, Union, cast
@@ -96,10 +97,6 @@ def millis_interval(start: datetime, end: datetime) -> int:
return millis
-class CachedSession(CacheMixin, requests.Session):
- pass
-
-
class CachedLimiterSession(CacheMixin, LimiterMixin, requests.Session):
pass
@@ -146,11 +143,11 @@ def __init__(
if not cache_name:
session_kwargs['backend'] = 'memory'
else:
- session_kwargs['cache_name'] = cache_name
- session_kwargs['allowable_methods'] = ['GET', 'POST']
- session_kwargs['ignored_parameters'] = ['Authorization']
- session_kwargs['cache_control'] = True
- session_cls = CachedSession
+ session_kwargs["cache_name"] = cache_name
+ session_kwargs["allowable_methods"] = ["GET", "POST"]
+ session_kwargs["ignored_parameters"] = ["Authorization"]
+ session_kwargs["cache_control"] = True
+ session_cls = requests_cache.CachedSession
if 'PYTEST_CURRENT_TEST' not in os.environ:
if limiter:
From 2fc05748a29bf63a4dbf592dfefb24d7d0c52f13 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Tue, 28 Jul 2026 15:58:04 -0700
Subject: [PATCH 21/57] Use adapters to avoid MRO errors in pkgs which import
this library but also monkeypatch sessions
---
pori_python/graphkb/util.py | 75 ++++++++++++++++++-------------------
1 file changed, 36 insertions(+), 39 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index ddd2c45a..f0b92e6f 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -1,26 +1,17 @@
-import requests
-from requests.adapters import HTTPAdapter
-
-import hashlib
import json
import logging
import os
import re
import time
from datetime import datetime
-from pyrate_limiter import Duration, Limiter
-
-try:
- from pyrate_limiter import Rate
-except ImportError:
- from pyrate_limiter import RequestRate as Rate
+from typing import Dict, Iterable, List, Optional, Union, cast
+from urllib.parse import urlsplit
-import requests_cache
-from requests_cache import CacheMixin
-from requests_ratelimiter import LimiterMixin
-from typing import Any, Dict, Iterable, List, Optional, Union, cast
+import requests
+from requests.adapters import HTTPAdapter
+from requests_cache import CachedSession
+from requests_ratelimiter import LimiterAdapter
from urllib3.util.retry import Retry
-from urllib.parse import urlsplit
from pori_python.types import ParsedVariant, PositionalVariant, Record
@@ -30,7 +21,7 @@
# https://stackoverflow.com/questions/11029717/how-do-i-disable-log-messages-from-the-requests-library
logger = logging.getLogger('graphkb')
-LIMITER = Limiter(Rate(3, Duration.SECOND))
+DEFAULT_LIMITER = LimiterAdapter(per_second=3)
def convert_to_rid_list(records: Iterable[Record]) -> List[str]:
@@ -97,10 +88,6 @@ def millis_interval(start: datetime, end: datetime) -> int:
return millis
-class CachedLimiterSession(CacheMixin, LimiterMixin, requests.Session):
- pass
-
-
class GraphKBConnection:
def __init__(
self,
@@ -111,7 +98,7 @@ def __init__(
cache_name: str = '',
only_if_cached: bool = False,
session: Optional[requests.Session] = None,
- limiter: Limiter = LIMITER,
+ limiter: LimiterAdapter = DEFAULT_LIMITER,
**session_kwargs,
):
"""
@@ -140,33 +127,43 @@ def __init__(
raise NotImplementedError('cache_name only applies when use_global_cache is True')
if use_global_cache:
+ session_cls = CachedSession
if not cache_name:
session_kwargs['backend'] = 'memory'
else:
- session_kwargs["cache_name"] = cache_name
- session_kwargs["allowable_methods"] = ["GET", "POST"]
- session_kwargs["ignored_parameters"] = ["Authorization"]
- session_kwargs["cache_control"] = True
- session_cls = requests_cache.CachedSession
-
- if 'PYTEST_CURRENT_TEST' not in os.environ:
- if limiter:
- session_kwargs['limiter'] = limiter
- session_cls = CachedLimiterSession
+ session_kwargs['cache_name'] = cache_name
+ session_kwargs['allowable_methods'] = ['GET', 'POST']
+ session_kwargs['ignored_parameters'] = ['Authorization']
+ session_kwargs['cache_control'] = True
+
+ if 'PYTEST_CURRENT_TEST' in os.environ or only_if_cached:
+ logging.warning(
+ f'rate limiting is by default turned off for tests and cache-only queries'
+ )
+ limiter = None
if not session:
self.http = session_cls(**session_kwargs)
else:
self.http = session
- retries = Retry(
- total=100,
- connect=5,
- status=5,
- backoff_factor=5,
- status_forcelist=[429, 500, 502, 503, 504],
- )
+
+ if limiter is not None:
+ self.http.mount('http://', limiter)
+ self.http.mount('https://', limiter)
+
+ if not only_if_cached:
+ # requests-cache returns 504 when something is not in cache, since we don't want to fetch networkx requests when this flag is set, retries are redundant
+ retries = Retry(
+ total=100,
+ connect=5,
+ status=5,
+ backoff_factor=5,
+ status_forcelist=[429, 500, 502, 503, 504],
+ )
+ self.http.mount('http://', HTTPAdapter(max_retries=retries))
+ self.http.mount('https://', HTTPAdapter(max_retries=retries))
self.only_if_cached = only_if_cached
- self.http.mount('https://', HTTPAdapter(max_retries=retries))
+
self.token = ''
self.token_kc = ''
self.url = url
From 3f16684aaa28e4d3327b61e6801cf7ee75fca678 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Fri, 28 Aug 2026 16:50:41 -0700
Subject: [PATCH 22/57] - DEVSU-3011 - Update get_ipr_analyst_comments method
with 'projects' key in queried variant-text being a list instead of single
value (since projects and variant texts now have many-to-many relationship
https://github.com/bcgsc/pori_ipr_api/pull/499) - Update project name
matching logic to new projects data structure
---
pori_python/ipr/summary.py | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/pori_python/ipr/summary.py b/pori_python/ipr/summary.py
index cfe60868..de8fb99b 100644
--- a/pori_python/ipr/summary.py
+++ b/pori_python/ipr/summary.py
@@ -434,12 +434,12 @@ def get_ipr_analyst_comments(
project_matches = [
item
for item in itemlist
- if 'project' in item.keys() and item['project']['name'] == project_name
+ if 'projects' in item.keys() and project_name in [p['name'] for p in item['projects']]
]
if project_matches:
itemlist = project_matches
elif include_nonspecific_project:
- itemlist = [item for item in itemlist if 'project' not in item.keys()]
+ itemlist = [item for item in itemlist if 'projects' not in item.keys()]
else:
itemlist = []
@@ -463,7 +463,6 @@ def get_ipr_analyst_comments(
)
> 0
]
-
if disease_matches:
itemlist = disease_matches
elif include_nonspecific_disease:
From b0dcdcf195fbd11bfd405eb84f74b8e4ef8bb4f1 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Fri, 28 Aug 2026 16:52:19 -0700
Subject: [PATCH 23/57] - Update unit test
---
tests/test_ipr/test_summary.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index 248a99f2..3705ce6a 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -166,14 +166,14 @@ def test_multiple_diseases_only_matches(self):
'variantName': 'ERBB2 amplification',
'cancerType': [],
'template': {'name': 'test3'},
- 'project': {'name': 'test2'},
+ 'projects': [{'name': 'test2'}],
},
{
'text': 'normal
',
'variantName': 'ERBB2 amplification',
'cancerType': ['test1', 'test'],
'template': {'name': 'test3'},
- 'project': {'name': 'test2'},
+ 'projects': [{'name': 'test2'}],
},
{
'text': 'no project
',
@@ -185,7 +185,7 @@ def test_multiple_diseases_only_matches(self):
'text': 'no template
',
'variantName': 'ERBB2 amplification',
'cancerType': ['test1', 'test'],
- 'project': {'name': 'test2'},
+ 'projects': [{'name': 'test2'}],
},
],
[
@@ -194,7 +194,7 @@ def test_multiple_diseases_only_matches(self):
'variantName': 'second variant',
'cancerType': ['test1', 'test'],
'template': {'name': 'test3'},
- 'project': {'name': 'test2'},
+ 'projects': [{'name': 'test2'}],
},
],
]
From c5491e13e83177c302e7da67bc1ce097e6e0546b Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Fri, 28 Aug 2026 16:57:16 -0700
Subject: [PATCH 24/57] - Code lint
---
pori_python/ipr/summary.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/pori_python/ipr/summary.py b/pori_python/ipr/summary.py
index de8fb99b..41b0f95b 100644
--- a/pori_python/ipr/summary.py
+++ b/pori_python/ipr/summary.py
@@ -434,7 +434,8 @@ def get_ipr_analyst_comments(
project_matches = [
item
for item in itemlist
- if 'projects' in item.keys() and project_name in [p['name'] for p in item['projects']]
+ if 'projects' in item.keys()
+ and project_name in [p['name'] for p in item['projects']]
]
if project_matches:
itemlist = project_matches
From 5af16498e4aa4a184dae7a0631efe28c454179fe Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Tue, 1 Sep 2026 13:27:35 -0700
Subject: [PATCH 25/57] - DEVSU-3013 - Return variantType and variant key when
creating new key alterations instead of hardcoded variant display names, so
IPR API can look for associated variant and create association during report
creation process
---
pori_python/ipr/ipr.py | 34 +++++++++++++++++++++++++---------
1 file changed, 25 insertions(+), 9 deletions(-)
diff --git a/pori_python/ipr/ipr.py b/pori_python/ipr/ipr.py
index 9a9afb38..4b197337 100644
--- a/pori_python/ipr/ipr.py
+++ b/pori_python/ipr/ipr.py
@@ -322,15 +322,22 @@ def create_key_alterations(
counts[type_mapping[variant_type]].add(variant_key)
- if variant_type == 'exp':
- alterations.append(f'{variant.get("gene", "")} ({variant.get("expressionState")})')
- elif variant_type == 'cnv':
- alterations.append(f'{variant.get("gene", "")} ({variant.get("cnvState")})')
- # only show germline if relevant
- elif kb_match['category'] in GERMLINE_BASE_TERMS and variant.get('germline'):
- alterations.append(f'germline {variant["variant"]}')
+ if kb_match['category'] in GERMLINE_BASE_TERMS:
+ # only include germline-category matches when the observed variant is germline
+ if variant.get('germline'):
+ alterations.append(
+ {
+ "variantType": variant_type,
+ "variant": variant['key'],
+ },
+ )
else:
- alterations.append(variant['variant'])
+ alterations.append(
+ {
+ "variantType": variant_type,
+ "variant": variant['key'],
+ },
+ )
counted_variants = set.union(*counts.values())
counts['variantsUnknown'] = set()
@@ -340,8 +347,17 @@ def create_key_alterations(
if variant['variant'] and variant['key'] not in counted_variants:
counts['variantsUnknown'].add(variant['key'])
+ unique_alterations = []
+ seen = set()
+ for alt in alterations:
+ dedupe_key = (alt['variantType'], alt['variant'])
+ if dedupe_key in seen:
+ continue
+ seen.add(dedupe_key)
+ unique_alterations.append(alt)
+
return (
- [{'geneVariant': alt} for alt in set(alterations)],
+ unique_alterations,
{k: len(v) for k, v in counts.items()},
)
From 4b538b850bf25c0a30cdc504d029e6e1c5af3764 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Tue, 1 Sep 2026 13:33:36 -0700
Subject: [PATCH 26/57] - Code lint/ruff
---
pori_python/ipr/ipr.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/pori_python/ipr/ipr.py b/pori_python/ipr/ipr.py
index 4b197337..1752c4e5 100644
--- a/pori_python/ipr/ipr.py
+++ b/pori_python/ipr/ipr.py
@@ -327,15 +327,15 @@ def create_key_alterations(
if variant.get('germline'):
alterations.append(
{
- "variantType": variant_type,
- "variant": variant['key'],
+ 'variantType': variant_type,
+ 'variant': variant['key'],
},
)
else:
alterations.append(
{
- "variantType": variant_type,
- "variant": variant['key'],
+ 'variantType': variant_type,
+ 'variant': variant['key'],
},
)
From 8ff46a457e7b262ddca73841da72c03a5d309ba6 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Wed, 2 Sep 2026 14:12:39 -0700
Subject: [PATCH 27/57] - Update test_ipr key alterations unit test to assert
that key alterations created contain the same variantType and variant key
values as mock variants
---
tests/test_ipr/test_ipr.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/tests/test_ipr/test_ipr.py b/tests/test_ipr/test_ipr.py
index 687c14b4..ba270153 100644
--- a/tests/test_ipr/test_ipr.py
+++ b/tests/test_ipr/test_ipr.py
@@ -1034,3 +1034,9 @@ def test_create_key_alterations_includes_only_pruned_kbmatches(self):
# partial matches is false
assert len(key_alts1) == 3
assert len(key_alts2) == 4
+ assert {(alt['variantType'], alt['variant']) for alt in key_alts1} == {
+ (variant['variantType'], variant['key']) for variant in ALL_VARIANTS[:3]
+ }
+ assert {(alt['variantType'], alt['variant']) for alt in key_alts2} == {
+ (variant['variantType'], variant['key']) for variant in ALL_VARIANTS[:4]
+ }
From 7d230cd98e2503615ee6a6375baf79603bd5a09a Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Wed, 2 Sep 2026 14:28:24 -0700
Subject: [PATCH 28/57] - Update test_summary unit tests - Integrate
IprConnection in mock result outputs so changes in IPR schema would fail
tests here
---
tests/test_ipr/test_summary.py | 291 +++++++++++++++++++--------------
1 file changed, 170 insertions(+), 121 deletions(-)
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index 3705ce6a..e4245864 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -1,6 +1,7 @@
from copy import copy
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
+from pori_python.ipr.connection import IprConnection
from pori_python.ipr.summary import (
GRAPHKB_GUI,
get_ipr_analyst_comments,
@@ -202,163 +203,207 @@ def test_multiple_diseases_only_matches(self):
no_comments_found_output = 'No comments found in IPR for variants in this report'
+def make_ipr_get_mock(results):
+ """Build a requests.request mock so IprConnection.get() runs for real against fake HTTP responses."""
+ responses = [
+ MagicMock(json=MagicMock(return_value=r), raise_for_status=MagicMock()) for r in results
+ ]
+ return MagicMock(side_effect=responses)
+
+
class TestVariantTextFromIPR:
def test_gets_fully_matched_output_when_possible(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
matches = [{'kbVariant': 'ERBB2 amplification'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=True,
- include_nonspecific_template=True,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock(copy(mock_ipr_results)),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=True,
+ include_nonspecific_template=True,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[1] == 'ERBB2 amplification (test1,test)
'
assert summary_lines[2] == 'normal
'
assert len(summary_lines) == 3
def test_omits_nonspecific_project_matches_when_specified(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
matches = [{'kbVariant': 'ERBB2 amplification'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='notfound',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=True,
- include_nonspecific_template=True,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock(copy(mock_ipr_results)),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='notfound',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=True,
+ include_nonspecific_template=True,
+ )
assert ipr_summary == no_comments_found_output
def test_omits_nonspecific_template_matches_when_specified(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
matches = [{'kbVariant': 'ERBB2 amplification'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='notfound',
- include_nonspecific_project=True,
- include_nonspecific_disease=True,
- include_nonspecific_template=False,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock(copy(mock_ipr_results)),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='notfound',
+ include_nonspecific_project=True,
+ include_nonspecific_disease=True,
+ include_nonspecific_template=False,
+ )
assert ipr_summary == no_comments_found_output
def test_omits_nonspecific_disease_matches_when_specified(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
matches = [{'kbVariant': 'ERBB2 amplification'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='notfound',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=True,
- include_nonspecific_disease=False,
- include_nonspecific_template=True,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock(copy(mock_ipr_results)),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='notfound',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=True,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=True,
+ )
assert ipr_summary == no_comments_found_output
def test_includes_nonspecific_project_matches_when_specified(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
matches = [{'kbVariant': 'ERBB2 amplification'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='notfound',
- report_type='test3',
- include_nonspecific_project=True,
- include_nonspecific_disease=False,
- include_nonspecific_template=False,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock(copy(mock_ipr_results)),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='notfound',
+ report_type='test3',
+ include_nonspecific_project=True,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=False,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[2] == 'no project
'
assert len(summary_lines) == 3
def test_includes_nonspecific_template_matches_when_specified(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
matches = [{'kbVariant': 'ERBB2 amplification'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='notfound',
- include_nonspecific_project=False,
- include_nonspecific_disease=False,
- include_nonspecific_template=True,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock(copy(mock_ipr_results)),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='notfound',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=True,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[2] == 'no template
'
assert len(summary_lines) == 3
def test_includes_nonspecific_disease_matches_when_specified(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
matches = [{'kbVariant': 'ERBB2 amplification'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='notfound',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=True,
- include_nonspecific_template=False,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock(copy(mock_ipr_results)),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='notfound',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=True,
+ include_nonspecific_template=False,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[1] == 'ERBB2 amplification (no specific cancer types)
'
assert summary_lines[2] == 'no cancerType
'
assert len(summary_lines) == 3
def test_includes_all_graphkb_disease_matches(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
matches = [{'kbVariant': 'ERBB2 amplification'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='notfound',
- disease_match_names=['TEST1'],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=False,
- include_nonspecific_template=False,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock(copy(mock_ipr_results)),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='notfound',
+ disease_match_names=['TEST1'],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=False,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[1] == 'ERBB2 amplification (test1,test)
'
assert summary_lines[2] == 'normal
'
assert len(summary_lines) == 3
def test_prepare_section_for_multiple_variants(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
# NB this test relies on matches being processed in this order
matches = [{'kbVariant': 'ERBB2 amplification'}, {'kbVariant': 'second variant'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=False,
- include_nonspecific_template=False,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock(copy(mock_ipr_results)),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=False,
+ )
summary_lines = ipr_summary.split('\n')
assert len(summary_lines) == 5
assert (
@@ -367,17 +412,21 @@ def test_prepare_section_for_multiple_variants(self):
)
def test_empty_section_when_no_variant_match(self):
- ipr_conn = MagicMock(get=MagicMock(side_effect=[[], []]))
matches = [{'kbVariant': 'notfound1'}, {'kbVariant': 'notfound2'}]
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=False,
- include_nonspecific_template=False,
- )
+ with patch(
+ 'pori_python.ipr.connection.requests.request',
+ make_ipr_get_mock([[], []]),
+ ):
+ ipr_conn = IprConnection('user', 'pass')
+ ipr_summary = get_ipr_analyst_comments(
+ ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=False,
+ )
assert ipr_summary == no_comments_found_output
From 69342b3fbe0ad582749e200a8333769aa303f162 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Tue, 8 Sep 2026 14:39:22 -0700
Subject: [PATCH 29/57] - Update test_summary to include integrated test to IPR
API for variant text schema - Validate schema generated by mock ipr results
with actual schema from IPR API
---
tests/test_ipr/test_summary.py | 336 +++++++++++++++++----------------
1 file changed, 169 insertions(+), 167 deletions(-)
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index e4245864..94ae8108 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -1,5 +1,9 @@
+import os
from copy import copy
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock
+
+import jsonschema
+import pytest
from pori_python.ipr.connection import IprConnection
from pori_python.ipr.summary import (
@@ -9,6 +13,8 @@
substitute_sentence_template,
)
+from .constants import EXCLUDE_INTEGRATION_TESTS
+
class TestGetPreferredDrugRepresentation:
def test_prefers_non_alias(self):
@@ -203,33 +209,65 @@ def test_multiple_diseases_only_matches(self):
no_comments_found_output = 'No comments found in IPR for variants in this report'
-def make_ipr_get_mock(results):
- """Build a requests.request mock so IprConnection.get() runs for real against fake HTTP responses."""
- responses = [
- MagicMock(json=MagicMock(return_value=r), raise_for_status=MagicMock()) for r in results
- ]
- return MagicMock(side_effect=responses)
+def validate_mock_ipr_results_against_schema(schema, results):
+ # Since the IPR generated schema has required fields, but the purpose of the test is to prevent
+ # schema drift and not to validate POST payloads to variant-text, we create a custom validator
+ # that ignores the required fields and only validates the properties that are defined in the schema.
+ custom_validators = dict(jsonschema.Draft7Validator.VALIDATORS)
+ custom_validators.pop('required', None)
+ non_required_validator = jsonschema.validators.create(
+ meta_schema=jsonschema.Draft7Validator.META_SCHEMA,
+ validators=custom_validators,
+ version='draft7-lazy',
+ )
+ validator = non_required_validator(schema)
+ declared_properties = set(schema.get('properties', {}))
+
+ for group_index, group in enumerate(results):
+ for record_index, record in enumerate(group):
+ location = f'group {group_index} record {record_index}'
+ unknown = sorted(set(record) - declared_properties)
+ assert not unknown, (
+ f'{location}: properties {unknown} are not defined in the IPR variant-text schema'
+ )
+ errors = sorted(validator.iter_errors(record), key=lambda err: list(err.path))
+ assert not errors, f'{location}: ' + '; '.join(
+ f'{list(err.path)}: {err.message}' for err in errors
+ )
+
+
+def make_ipr_connection():
+ return IprConnection(
+ username=os.environ.get('IPR_USER', os.environ['USER']),
+ password=os.environ['IPR_PASS'],
+ url=os.environ['IPR_TEST_URL'],
+ )
+
+
+# DEVSU-3011 adding IPR integration test to validate variant text schema from the IPR API side to prevent schema drift
+@pytest.mark.skipif(EXCLUDE_INTEGRATION_TESTS, reason='excluding long running integration tests')
+class TestVariantTextSchema:
+ def test_mock_ipr_results_match_variant_text_schema(self):
+ ipr_conn = make_ipr_connection()
+ schema = ipr_conn.get('variant-text/schema')
+ validate_mock_ipr_results_against_schema(schema, mock_ipr_results)
class TestVariantTextFromIPR:
def test_gets_fully_matched_output_when_possible(self):
matches = [{'kbVariant': 'ERBB2 amplification'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock(copy(mock_ipr_results)),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=True,
- include_nonspecific_template=True,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=True,
+ include_nonspecific_template=True,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[1] == 'ERBB2 amplification (test1,test)
'
assert summary_lines[2] == 'normal
'
@@ -237,126 +275,102 @@ def test_gets_fully_matched_output_when_possible(self):
def test_omits_nonspecific_project_matches_when_specified(self):
matches = [{'kbVariant': 'ERBB2 amplification'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock(copy(mock_ipr_results)),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='notfound',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=True,
- include_nonspecific_template=True,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='notfound',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=True,
+ include_nonspecific_template=True,
+ )
assert ipr_summary == no_comments_found_output
def test_omits_nonspecific_template_matches_when_specified(self):
matches = [{'kbVariant': 'ERBB2 amplification'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock(copy(mock_ipr_results)),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='notfound',
- include_nonspecific_project=True,
- include_nonspecific_disease=True,
- include_nonspecific_template=False,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='notfound',
+ include_nonspecific_project=True,
+ include_nonspecific_disease=True,
+ include_nonspecific_template=False,
+ )
assert ipr_summary == no_comments_found_output
def test_omits_nonspecific_disease_matches_when_specified(self):
matches = [{'kbVariant': 'ERBB2 amplification'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock(copy(mock_ipr_results)),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='notfound',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=True,
- include_nonspecific_disease=False,
- include_nonspecific_template=True,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='notfound',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=True,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=True,
+ )
assert ipr_summary == no_comments_found_output
def test_includes_nonspecific_project_matches_when_specified(self):
matches = [{'kbVariant': 'ERBB2 amplification'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock(copy(mock_ipr_results)),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='notfound',
- report_type='test3',
- include_nonspecific_project=True,
- include_nonspecific_disease=False,
- include_nonspecific_template=False,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='notfound',
+ report_type='test3',
+ include_nonspecific_project=True,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=False,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[2] == 'no project
'
assert len(summary_lines) == 3
def test_includes_nonspecific_template_matches_when_specified(self):
matches = [{'kbVariant': 'ERBB2 amplification'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock(copy(mock_ipr_results)),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='notfound',
- include_nonspecific_project=False,
- include_nonspecific_disease=False,
- include_nonspecific_template=True,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='test1',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='notfound',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=True,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[2] == 'no template
'
assert len(summary_lines) == 3
def test_includes_nonspecific_disease_matches_when_specified(self):
matches = [{'kbVariant': 'ERBB2 amplification'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock(copy(mock_ipr_results)),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='notfound',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=True,
- include_nonspecific_template=False,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='notfound',
+ disease_match_names=[],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=True,
+ include_nonspecific_template=False,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[1] == 'ERBB2 amplification (no specific cancer types)
'
assert summary_lines[2] == 'no cancerType
'
@@ -364,22 +378,18 @@ def test_includes_nonspecific_disease_matches_when_specified(self):
def test_includes_all_graphkb_disease_matches(self):
matches = [{'kbVariant': 'ERBB2 amplification'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock(copy(mock_ipr_results)),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='notfound',
- disease_match_names=['TEST1'],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=False,
- include_nonspecific_template=False,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='notfound',
+ disease_match_names=['TEST1'],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=False,
+ )
summary_lines = ipr_summary.split('\n')
assert summary_lines[1] == 'ERBB2 amplification (test1,test)
'
assert summary_lines[2] == 'normal
'
@@ -388,22 +398,18 @@ def test_includes_all_graphkb_disease_matches(self):
def test_prepare_section_for_multiple_variants(self):
# NB this test relies on matches being processed in this order
matches = [{'kbVariant': 'ERBB2 amplification'}, {'kbVariant': 'second variant'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock(copy(mock_ipr_results)),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=False,
- include_nonspecific_template=False,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=copy(mock_ipr_results)))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='notfound',
+ disease_match_names=['TEST1'],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=False,
+ )
summary_lines = ipr_summary.split('\n')
assert len(summary_lines) == 5
assert (
@@ -413,20 +419,16 @@ def test_prepare_section_for_multiple_variants(self):
def test_empty_section_when_no_variant_match(self):
matches = [{'kbVariant': 'notfound1'}, {'kbVariant': 'notfound2'}]
- with patch(
- 'pori_python.ipr.connection.requests.request',
- make_ipr_get_mock([[], []]),
- ):
- ipr_conn = IprConnection('user', 'pass')
- ipr_summary = get_ipr_analyst_comments(
- ipr_conn,
- matches=matches,
- disease_name='test1',
- disease_match_names=[],
- project_name='test2',
- report_type='test3',
- include_nonspecific_project=False,
- include_nonspecific_disease=False,
- include_nonspecific_template=False,
- )
+ mock_ipr_conn = MagicMock(get=MagicMock(side_effect=[[], []]))
+ ipr_summary = get_ipr_analyst_comments(
+ mock_ipr_conn,
+ matches=matches,
+ disease_name='notfound',
+ disease_match_names=['TEST1'],
+ project_name='test2',
+ report_type='test3',
+ include_nonspecific_project=False,
+ include_nonspecific_disease=False,
+ include_nonspecific_template=False,
+ )
assert ipr_summary == no_comments_found_output
From 5f0f053c22839cafc5bdf08a30ff42507cd871cf Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Wed, 9 Sep 2026 13:18:27 -0700
Subject: [PATCH 30/57] - Add documentation for the returned
genomicAlterationIdentified object
---
pori_python/ipr/ipr.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/pori_python/ipr/ipr.py b/pori_python/ipr/ipr.py
index 1752c4e5..45b88702 100644
--- a/pori_python/ipr/ipr.py
+++ b/pori_python/ipr/ipr.py
@@ -322,6 +322,10 @@ def create_key_alterations(
counts[type_mapping[variant_type]].add(variant_key)
+ # DEVSU-2013 returning an alteration object for IPR API to look up an observed variant for association at report creation.
+ # variantType: the type of variant (mut, cnv, sv, exp)
+ # variant: the observed variant's key property (eg: e90293983749cf4)
+ # Example value: {'variantType': 'mut', 'variant': 'e90293983749cf4'}
if kb_match['category'] in GERMLINE_BASE_TERMS:
# only include germline-category matches when the observed variant is germline
if variant.get('germline'):
@@ -332,6 +336,7 @@ def create_key_alterations(
},
)
else:
+ print(variant_type, variant['key'])
alterations.append(
{
'variantType': variant_type,
From 1de444a44f3df443d7749751d68a9ce71e655bf5 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Fri, 11 Sep 2026 10:34:20 -0700
Subject: [PATCH 31/57] - Update github actions test variable for ipr url for
integrated tests - Update ipr url variable name for consistency
---
.github/workflows/pytest.yml | 1 +
tests/test_ipr/test_summary.py | 2 +-
tests/test_ipr/test_upload.py | 6 +++---
3 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 7804a8a0..61c5011d 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -39,6 +39,7 @@ jobs:
env:
IPR_USER: ${{ secrets.IPR_TEST_USER }}
IPR_PASS: ${{ secrets.IPR_TEST_PASSWORD }}
+ IPR_URL: ${{ secrets.IPR_TEST_URL }}
GRAPHKB_USER: ${{ secrets.GKB_TEST_USER }}
GRAPHKB_PASS: ${{ secrets.GKB_TEST_PASS }}
GRAPHKB_URL: ${{ secrets.GKB_TEST_URL }}
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index 94ae8108..c7594a4e 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -240,7 +240,7 @@ def make_ipr_connection():
return IprConnection(
username=os.environ.get('IPR_USER', os.environ['USER']),
password=os.environ['IPR_PASS'],
- url=os.environ['IPR_TEST_URL'],
+ url=os.environ['IPR_URL'],
)
diff --git a/tests/test_ipr/test_upload.py b/tests/test_ipr/test_upload.py
index 6e7f9b05..4445b9c4 100644
--- a/tests/test_ipr/test_upload.py
+++ b/tests/test_ipr/test_upload.py
@@ -189,7 +189,7 @@ def loaded_reports(tmp_path_factory) -> Generator:
'--graphkb_password',
os.environ.get('GRAPHKB_PASS', os.environ['IPR_PASS']),
'--ipr_url',
- os.environ['IPR_TEST_URL'],
+ os.environ['IPR_URL'],
'--graphkb_url',
os.environ.get('GRAPHKB_URL', False),
'--therapeutics',
@@ -213,7 +213,7 @@ def loaded_reports(tmp_path_factory) -> Generator:
ipr_conn = IprConnection(
username=os.environ.get('IPR_USER', os.environ['USER']),
password=os.environ['IPR_PASS'],
- url=os.environ['IPR_TEST_URL'],
+ url=os.environ['IPR_URL'],
)
loaded_report = ipr_conn.get(uri=f'reports?searchText={patient_id}')
async_loaded_report = ipr_conn.get(uri=f'reports?searchText={async_patient_id}')
@@ -233,7 +233,7 @@ def get_section(loaded_report, section_name):
ipr_conn = IprConnection(
username=os.environ.get('IPR_USER', os.environ['USER']),
password=os.environ['IPR_PASS'],
- url=os.environ['IPR_TEST_URL'],
+ url=os.environ['IPR_URL'],
)
return ipr_conn.get(uri=f'reports/{ident}/{section_name}')
From d405ecbdb3e629330a33dfeddc63a6513428bfde Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Fri, 11 Sep 2026 10:35:33 -0700
Subject: [PATCH 32/57] - Remove leftover debugging print()
---
pori_python/ipr/ipr.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/pori_python/ipr/ipr.py b/pori_python/ipr/ipr.py
index 45b88702..26c0a2dc 100644
--- a/pori_python/ipr/ipr.py
+++ b/pori_python/ipr/ipr.py
@@ -336,7 +336,6 @@ def create_key_alterations(
},
)
else:
- print(variant_type, variant['key'])
alterations.append(
{
'variantType': variant_type,
From 81efa97604f29a5c9e1e8a40e810d8da40485038 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Fri, 11 Sep 2026 11:51:27 -0700
Subject: [PATCH 33/57] - Update unit test and yaml to use dev api url instead
of staging temporarily until api release
---
.github/workflows/pytest.yml | 1 +
.github/workflows/quick-pytest.yml | 1 +
tests/test_ipr/test_summary.py | 3 ++-
3 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 61c5011d..9e1ab6d4 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -40,6 +40,7 @@ jobs:
IPR_USER: ${{ secrets.IPR_TEST_USER }}
IPR_PASS: ${{ secrets.IPR_TEST_PASSWORD }}
IPR_URL: ${{ secrets.IPR_TEST_URL }}
+ IPR_DEV_URL: ${{ secrets.IPR_DEV_TEST_URL }}
GRAPHKB_USER: ${{ secrets.GKB_TEST_USER }}
GRAPHKB_PASS: ${{ secrets.GKB_TEST_PASS }}
GRAPHKB_URL: ${{ secrets.GKB_TEST_URL }}
diff --git a/.github/workflows/quick-pytest.yml b/.github/workflows/quick-pytest.yml
index aef2b56d..0adf425b 100644
--- a/.github/workflows/quick-pytest.yml
+++ b/.github/workflows/quick-pytest.yml
@@ -35,6 +35,7 @@ jobs:
IPR_USER: ${{ secrets.IPR_TEST_USER }}
IPR_PASS: ${{ secrets.IPR_TEST_PASSWORD }}
IPR_URL: ${{ secrets.IPR_TEST_URL }}
+ IPR_DEV_URL: ${{ secrets.IPR_DEV_TEST_URL }}
GRAPHKB_USER: ${{ secrets.GKB_TEST_USER }}
GRAPHKB_PASS: ${{ secrets.GKB_TEST_PASS }}
GRAPHKB_URL: ${{ secrets.GKB_TEST_URL }}
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index c7594a4e..83b5a209 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -240,7 +240,8 @@ def make_ipr_connection():
return IprConnection(
username=os.environ.get('IPR_USER', os.environ['USER']),
password=os.environ['IPR_PASS'],
- url=os.environ['IPR_URL'],
+ # TO-DO: DEVSU-3011 revert dev url used for testing back to IPR_URL which uses staging api url. Using dev url temporarily before API is released to prevent github test failing
+ url=os.environ['IPR_DEV_URL'],
)
From 2ea5d34a3a0bcad6de87bb2bba382728acff01ad Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Mon, 14 Sep 2026 16:45:24 -0700
Subject: [PATCH 34/57] - Update IPR API url for integrated tests
---
.github/workflows/pytest.yml | 1 -
.github/workflows/quick-pytest.yml | 1 -
tests/test_ipr/test_summary.py | 2 +-
3 files changed, 1 insertion(+), 3 deletions(-)
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 9e1ab6d4..61c5011d 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -40,7 +40,6 @@ jobs:
IPR_USER: ${{ secrets.IPR_TEST_USER }}
IPR_PASS: ${{ secrets.IPR_TEST_PASSWORD }}
IPR_URL: ${{ secrets.IPR_TEST_URL }}
- IPR_DEV_URL: ${{ secrets.IPR_DEV_TEST_URL }}
GRAPHKB_USER: ${{ secrets.GKB_TEST_USER }}
GRAPHKB_PASS: ${{ secrets.GKB_TEST_PASS }}
GRAPHKB_URL: ${{ secrets.GKB_TEST_URL }}
diff --git a/.github/workflows/quick-pytest.yml b/.github/workflows/quick-pytest.yml
index 0adf425b..aef2b56d 100644
--- a/.github/workflows/quick-pytest.yml
+++ b/.github/workflows/quick-pytest.yml
@@ -35,7 +35,6 @@ jobs:
IPR_USER: ${{ secrets.IPR_TEST_USER }}
IPR_PASS: ${{ secrets.IPR_TEST_PASSWORD }}
IPR_URL: ${{ secrets.IPR_TEST_URL }}
- IPR_DEV_URL: ${{ secrets.IPR_DEV_TEST_URL }}
GRAPHKB_USER: ${{ secrets.GKB_TEST_USER }}
GRAPHKB_PASS: ${{ secrets.GKB_TEST_PASS }}
GRAPHKB_URL: ${{ secrets.GKB_TEST_URL }}
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index 83b5a209..37eee600 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -241,7 +241,7 @@ def make_ipr_connection():
username=os.environ.get('IPR_USER', os.environ['USER']),
password=os.environ['IPR_PASS'],
# TO-DO: DEVSU-3011 revert dev url used for testing back to IPR_URL which uses staging api url. Using dev url temporarily before API is released to prevent github test failing
- url=os.environ['IPR_DEV_URL'],
+ url=os.environ['IPR_URL'],
)
From bd81a0e8ea02613255eff0e0f383509e9cbc93ac Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Tue, 15 Sep 2026 09:39:35 -0700
Subject: [PATCH 35/57] - Update ipr connection get request with header
specifying no data body is provided
---
tests/test_ipr/test_summary.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index 37eee600..996d6797 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -2,6 +2,7 @@
from copy import copy
from unittest.mock import MagicMock
+import json
import jsonschema
import pytest
@@ -250,7 +251,10 @@ def make_ipr_connection():
class TestVariantTextSchema:
def test_mock_ipr_results_match_variant_text_schema(self):
ipr_conn = make_ipr_connection()
- schema = ipr_conn.get('variant-text/schema')
+ headers = json.dumps({
+ 'Content-Length': '0', # Tells the server explicitly no body is processing
+ })
+ schema = ipr_conn.get('variant-text/schema', headers=headers)
validate_mock_ipr_results_against_schema(schema, mock_ipr_results)
From e8f125b746ee3b79e5b52b03a6138a8bd8c35cc3 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Tue, 15 Sep 2026 09:41:53 -0700
Subject: [PATCH 36/57] - Code ruff
---
tests/test_ipr/test_summary.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index 996d6797..515a967e 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -251,9 +251,11 @@ def make_ipr_connection():
class TestVariantTextSchema:
def test_mock_ipr_results_match_variant_text_schema(self):
ipr_conn = make_ipr_connection()
- headers = json.dumps({
- 'Content-Length': '0', # Tells the server explicitly no body is processing
- })
+ headers = json.dumps(
+ {
+ 'Content-Length': '0', # Tells the server explicitly no body is processing
+ }
+ )
schema = ipr_conn.get('variant-text/schema', headers=headers)
validate_mock_ipr_results_against_schema(schema, mock_ipr_results)
From 36d7a852bbcdd170202d050abb59266ea0da2c0c Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Tue, 15 Sep 2026 11:21:18 -0700
Subject: [PATCH 37/57] - Remove headers specification in integrated unit test
---
tests/test_ipr/test_summary.py | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index 515a967e..c860a224 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -251,12 +251,7 @@ def make_ipr_connection():
class TestVariantTextSchema:
def test_mock_ipr_results_match_variant_text_schema(self):
ipr_conn = make_ipr_connection()
- headers = json.dumps(
- {
- 'Content-Length': '0', # Tells the server explicitly no body is processing
- }
- )
- schema = ipr_conn.get('variant-text/schema', headers=headers)
+ schema = ipr_conn.get('variant-text/schema')
validate_mock_ipr_results_against_schema(schema, mock_ipr_results)
From 399e7be22e67fb6c36bb0a6e27d3eec050f059b5 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Tue, 15 Sep 2026 13:28:14 -0700
Subject: [PATCH 38/57] - Switch from connection get wrapper to request to
avoid empty data body in request
---
tests/test_ipr/test_summary.py | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index c860a224..5fe6b984 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -241,7 +241,6 @@ def make_ipr_connection():
return IprConnection(
username=os.environ.get('IPR_USER', os.environ['USER']),
password=os.environ['IPR_PASS'],
- # TO-DO: DEVSU-3011 revert dev url used for testing back to IPR_URL which uses staging api url. Using dev url temporarily before API is released to prevent github test failing
url=os.environ['IPR_URL'],
)
@@ -251,7 +250,16 @@ def make_ipr_connection():
class TestVariantTextSchema:
def test_mock_ipr_results_match_variant_text_schema(self):
ipr_conn = make_ipr_connection()
- schema = ipr_conn.get('variant-text/schema')
+ schema = ipr_conn.request(
+ 'variant-text/schema',
+ method='GET',
+ headers=json.dumps(
+ {
+ 'Content-Length': '0',
+ 'Accept': 'application/json',
+ }
+ ),
+ )
validate_mock_ipr_results_against_schema(schema, mock_ipr_results)
From 6b27d1ddba0d618cc75cff0e4fb027bbeadb52aa Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Wed, 16 Sep 2026 10:50:57 -0700
Subject: [PATCH 39/57] - Split integrated test from test summary to separate
test suite - Update ipr api url used for integrated tests - Fix type
declaration in summary
---
.github/workflows/pytest.yml | 1 +
.github/workflows/quick-pytest.yml | 1 +
pori_python/ipr/summary.py | 2 +-
tests/test_ipr/test_ipr_integration.py | 102 +++++++++++++++++++++++++
tests/test_ipr/test_summary.py | 61 ---------------
5 files changed, 105 insertions(+), 62 deletions(-)
create mode 100644 tests/test_ipr/test_ipr_integration.py
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 61c5011d..b1bb249c 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -40,6 +40,7 @@ jobs:
IPR_USER: ${{ secrets.IPR_TEST_USER }}
IPR_PASS: ${{ secrets.IPR_TEST_PASSWORD }}
IPR_URL: ${{ secrets.IPR_TEST_URL }}
+ IPR_INTEGRATION_TEST_URL: ${{ secrets.IPR_INTEGRATION_TEST_URL }}
GRAPHKB_USER: ${{ secrets.GKB_TEST_USER }}
GRAPHKB_PASS: ${{ secrets.GKB_TEST_PASS }}
GRAPHKB_URL: ${{ secrets.GKB_TEST_URL }}
diff --git a/.github/workflows/quick-pytest.yml b/.github/workflows/quick-pytest.yml
index aef2b56d..5bdf6534 100644
--- a/.github/workflows/quick-pytest.yml
+++ b/.github/workflows/quick-pytest.yml
@@ -35,6 +35,7 @@ jobs:
IPR_USER: ${{ secrets.IPR_TEST_USER }}
IPR_PASS: ${{ secrets.IPR_TEST_PASSWORD }}
IPR_URL: ${{ secrets.IPR_TEST_URL }}
+ IPR_INTEGRATION_TEST_URL: ${{ secrets.IPR_INTEGRATION_TEST_URL }}
GRAPHKB_USER: ${{ secrets.GKB_TEST_USER }}
GRAPHKB_PASS: ${{ secrets.GKB_TEST_PASS }}
GRAPHKB_URL: ${{ secrets.GKB_TEST_URL }}
diff --git a/pori_python/ipr/summary.py b/pori_python/ipr/summary.py
index 41b0f95b..6038eb07 100644
--- a/pori_python/ipr/summary.py
+++ b/pori_python/ipr/summary.py
@@ -385,7 +385,7 @@ def get_ipr_analyst_comments(
ipr_conn: IprConnection,
matches: Sequence[KbMatch] | Sequence[Hashabledict],
disease_name: str,
- disease_match_names: [str],
+ disease_match_names: list[str],
project_name: str,
report_type: str,
include_nonspecific_disease: bool = False,
diff --git a/tests/test_ipr/test_ipr_integration.py b/tests/test_ipr/test_ipr_integration.py
new file mode 100644
index 00000000..af9c5793
--- /dev/null
+++ b/tests/test_ipr/test_ipr_integration.py
@@ -0,0 +1,102 @@
+import os
+import json
+import jsonschema
+import pytest
+
+from pori_python.ipr.connection import IprConnection
+
+from .constants import EXCLUDE_INTEGRATION_TESTS
+
+
+mock_ipr_results = [
+ [
+ {
+ 'text': 'no cancerType
',
+ 'variantName': 'ERBB2 amplification',
+ 'cancerType': [],
+ 'template': {'name': 'test3'},
+ 'projects': [{'name': 'test2'}],
+ },
+ {
+ 'text': 'normal
',
+ 'variantName': 'ERBB2 amplification',
+ 'cancerType': ['test1', 'test'],
+ 'template': {'name': 'test3'},
+ 'projects': [{'name': 'test2'}],
+ },
+ {
+ 'text': 'no project
',
+ 'variantName': 'ERBB2 amplification',
+ 'cancerType': ['test1', 'test'],
+ 'template': {'name': 'test3'},
+ },
+ {
+ 'text': 'no template
',
+ 'variantName': 'ERBB2 amplification',
+ 'cancerType': ['test1', 'test'],
+ 'projects': [{'name': 'test2'}],
+ },
+ ],
+ [
+ {
+ 'text': 'normal, second variant
',
+ 'variantName': 'second variant',
+ 'cancerType': ['test1', 'test'],
+ 'template': {'name': 'test3'},
+ 'projects': [{'name': 'test2'}],
+ },
+ ],
+]
+
+
+def validate_mock_ipr_results_against_schema(schema, results):
+ # Since the IPR generated schema has required fields, but the purpose of the test is to prevent
+ # schema drift and not to validate POST payloads to variant-text, we create a custom validator
+ # that ignores the required fields and only validates the properties that are defined in the schema.
+ custom_validators = dict(jsonschema.Draft7Validator.VALIDATORS)
+ custom_validators.pop('required', None)
+ non_required_validator = jsonschema.validators.create(
+ meta_schema=jsonschema.Draft7Validator.META_SCHEMA,
+ validators=custom_validators,
+ version='draft7-lazy',
+ )
+ validator = non_required_validator(schema)
+ declared_properties = set(schema.get('properties', {}))
+
+ for group_index, group in enumerate(results):
+ for record_index, record in enumerate(group):
+ location = f'group {group_index} record {record_index}'
+ unknown = sorted(set(record) - declared_properties)
+ assert not unknown, (
+ f'{location}: properties {unknown} are not defined in the IPR variant-text schema'
+ )
+ errors = sorted(validator.iter_errors(record), key=lambda err: list(err.path))
+ assert not errors, f'{location}: ' + '; '.join(
+ f'{list(err.path)}: {err.message}' for err in errors
+ )
+
+
+def make_ipr_connection():
+ return IprConnection(
+ username=os.environ.get('IPR_USER', os.environ['USER']),
+ password=os.environ['IPR_PASS'],
+ url=os.environ['IPR_INTEGRATION_TEST_URL'],
+ )
+
+
+# DEVSU-3011 adding IPR integration test to validate variant text schema from the IPR API side to prevent schema drift
+@pytest.mark.skipif(EXCLUDE_INTEGRATION_TESTS, reason='excluding long running integration tests')
+class TestVariantTextSchema:
+ def test_mock_ipr_results_match_variant_text_schema(self):
+ ipr_conn = make_ipr_connection()
+ schema = ipr_conn.request(
+ 'variant-text/schema',
+ method='GET',
+ headers=json.dumps(
+ {
+ 'Content-Length': '0',
+ 'Accept': 'application/json',
+ }
+ ),
+ )
+ validate_mock_ipr_results_against_schema(schema, mock_ipr_results)
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index 5fe6b984..a2f0009f 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -1,12 +1,6 @@
-import os
from copy import copy
from unittest.mock import MagicMock
-import json
-import jsonschema
-import pytest
-
-from pori_python.ipr.connection import IprConnection
from pori_python.ipr.summary import (
GRAPHKB_GUI,
get_ipr_analyst_comments,
@@ -14,8 +8,6 @@
substitute_sentence_template,
)
-from .constants import EXCLUDE_INTEGRATION_TESTS
-
class TestGetPreferredDrugRepresentation:
def test_prefers_non_alias(self):
@@ -210,59 +202,6 @@ def test_multiple_diseases_only_matches(self):
no_comments_found_output = 'No comments found in IPR for variants in this report'
-def validate_mock_ipr_results_against_schema(schema, results):
- # Since the IPR generated schema has required fields, but the purpose of the test is to prevent
- # schema drift and not to validate POST payloads to variant-text, we create a custom validator
- # that ignores the required fields and only validates the properties that are defined in the schema.
- custom_validators = dict(jsonschema.Draft7Validator.VALIDATORS)
- custom_validators.pop('required', None)
- non_required_validator = jsonschema.validators.create(
- meta_schema=jsonschema.Draft7Validator.META_SCHEMA,
- validators=custom_validators,
- version='draft7-lazy',
- )
- validator = non_required_validator(schema)
- declared_properties = set(schema.get('properties', {}))
-
- for group_index, group in enumerate(results):
- for record_index, record in enumerate(group):
- location = f'group {group_index} record {record_index}'
- unknown = sorted(set(record) - declared_properties)
- assert not unknown, (
- f'{location}: properties {unknown} are not defined in the IPR variant-text schema'
- )
- errors = sorted(validator.iter_errors(record), key=lambda err: list(err.path))
- assert not errors, f'{location}: ' + '; '.join(
- f'{list(err.path)}: {err.message}' for err in errors
- )
-
-
-def make_ipr_connection():
- return IprConnection(
- username=os.environ.get('IPR_USER', os.environ['USER']),
- password=os.environ['IPR_PASS'],
- url=os.environ['IPR_URL'],
- )
-
-
-# DEVSU-3011 adding IPR integration test to validate variant text schema from the IPR API side to prevent schema drift
-@pytest.mark.skipif(EXCLUDE_INTEGRATION_TESTS, reason='excluding long running integration tests')
-class TestVariantTextSchema:
- def test_mock_ipr_results_match_variant_text_schema(self):
- ipr_conn = make_ipr_connection()
- schema = ipr_conn.request(
- 'variant-text/schema',
- method='GET',
- headers=json.dumps(
- {
- 'Content-Length': '0',
- 'Accept': 'application/json',
- }
- ),
- )
- validate_mock_ipr_results_against_schema(schema, mock_ipr_results)
-
-
class TestVariantTextFromIPR:
def test_gets_fully_matched_output_when_possible(self):
matches = [{'kbVariant': 'ERBB2 amplification'}]
From cfa31fefc5b7a8868ca191a244c530212ca5b3f0 Mon Sep 17 00:00:00 2001
From: Eleanor Lewis
Date: Wed, 16 Sep 2026 15:13:35 -0700
Subject: [PATCH 40/57] remove report key, add ident, lint
---
pori_python/ipr/main.py | 53 +++++++++++++++++++++++------------------
1 file changed, 30 insertions(+), 23 deletions(-)
diff --git a/pori_python/ipr/main.py b/pori_python/ipr/main.py
index d261cbd5..48b8504d 100644
--- a/pori_python/ipr/main.py
+++ b/pori_python/ipr/main.py
@@ -3,12 +3,13 @@
import argparse
import datetime
import json
-import jsonschema.exceptions
import logging
import os
-import pandas as pd
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser
-from typing import Callable, Dict, List, Optional, Sequence, Set
+from collections.abc import Callable, Sequence
+
+import jsonschema.exceptions
+import pandas as pd
from pori_python.graphkb import GraphKBConnection
from pori_python.graphkb.genes import get_gene_information
@@ -33,8 +34,8 @@
preprocess_cosmic,
preprocess_expression_variants,
preprocess_hla,
- preprocess_msi,
preprocess_hrd,
+ preprocess_msi,
preprocess_signature_variants,
preprocess_small_mutations,
preprocess_structural_variants,
@@ -42,14 +43,14 @@
validate_report_content,
)
from .ipr import (
+ add_transcript_flags,
create_key_alterations,
filter_structural_variants,
germline_kb_matches,
get_kb_disease_matches,
get_kb_matches_sections,
- select_expression_plots,
get_variant_flags,
- add_transcript_flags,
+ select_expression_plots,
)
from .summary import auto_analyst_comments, get_ipr_analyst_comments
from .therapeutic_options import create_therapeutic_options
@@ -65,7 +66,7 @@
def file_path(path: str) -> str:
if not os.path.exists(path):
- raise argparse.ArgumentTypeError(f'{repr(path)} is not a valid filename. does not exist')
+ raise argparse.ArgumentTypeError(f'{path!r} is not a valid filename. does not exist')
return path
@@ -215,7 +216,7 @@ def command_interface() -> None:
)
-def clean_unsupported_content(upload_content: Dict, ipr_spec: Dict = {}) -> Dict:
+def clean_unsupported_content(upload_content: dict, ipr_spec: dict = {}) -> dict:
"""Remove unsupported content.
This content is either added to facilitate creation
or to support upcoming and soon to be supported content that we would like
@@ -223,7 +224,7 @@ def clean_unsupported_content(upload_content: Dict, ipr_spec: Dict = {}) -> Dict
"""
if (
ipr_spec
- and 'components' in ipr_spec.keys()
+ and 'components' in ipr_spec
and 'schemas' in ipr_spec['components'].keys()
and 'genesCreate' in ipr_spec['components']['schemas'].keys()
and 'properties' in ipr_spec['components']['schemas']['genesCreate'].keys()
@@ -253,7 +254,7 @@ def clean_unsupported_content(upload_content: Dict, ipr_spec: Dict = {}) -> Dict
)
# remove any unhandled incompatible keys
- removed_keys: Dict[str, int] = {}
+ removed_keys: dict[str, int] = {}
for gene in upload_content['genes']:
unsupported_keys = [key for key in gene.keys() if key not in genes_spec]
for key in unsupported_keys:
@@ -315,7 +316,7 @@ def clean_unsupported_content(upload_content: Dict, ipr_spec: Dict = {}) -> Dict
return upload_content
-def create_report(**kwargs) -> Dict:
+def create_report(**kwargs) -> dict:
logger.warning("Deprecated function 'create_report' called - use ipr_report instead")
return ipr_report(**kwargs)
@@ -323,7 +324,7 @@ def create_report(**kwargs) -> Dict:
def ipr_report(
username: str,
password: str,
- content: Dict,
+ content: dict,
ipr_url: str = '',
log_level: str = 'info',
output_json_path: str = '',
@@ -336,7 +337,7 @@ def ipr_report(
generate_therapeutics: bool = False,
generate_comments: bool = True,
match_germline: bool = False,
- custom_kb_match_filter: Optional[Callable] = None,
+ custom_kb_match_filter: Callable | None = None,
async_upload: bool = False,
mins_to_wait: int = 10,
include_ipr_variant_text: bool = True,
@@ -349,7 +350,7 @@ def ipr_report(
ignore_extra_fields: bool = False,
tmb_high: float = TMB_SIGNATURE_HIGH_THRESHOLD,
transcript_flags: str = '',
-) -> Dict:
+) -> dict:
"""Run the matching and create the report JSON for upload to IPR.
Args:
@@ -431,7 +432,7 @@ def ipr_report(
transcript_flags_df = load_transcript_flags(transcript_flags)
# INPUT VARIANTS VALIDATION & PREPROCESSING (OBSERVED BIOMARKERS)
- signature_variants: List[IprSignatureVariant] = preprocess_signature_variants(
+ signature_variants: list[IprSignatureVariant] = preprocess_signature_variants(
[
*preprocess_cosmic(content.get('cosmicSignatures', [])), # includes dMMR
*preprocess_hla(content.get('hlaTypes', [])),
@@ -444,14 +445,14 @@ def ipr_report(
*preprocess_hrd(content.get('hrd', None)),
]
)
- small_mutations: List[IprSmallMutationVariant] = preprocess_small_mutations(
+ small_mutations: list[IprSmallMutationVariant] = preprocess_small_mutations(
content.get('smallMutations', [])
)
- structural_variants: List[IprFusionVariant] = preprocess_structural_variants(
+ structural_variants: list[IprFusionVariant] = preprocess_structural_variants(
content.get('structuralVariants', [])
)
- copy_variants: List[IprCopyVariant] = preprocess_copy_variants(content.get('copyVariants', []))
- expression_variants: List[IprExprVariant] = preprocess_expression_variants(
+ copy_variants: list[IprCopyVariant] = preprocess_copy_variants(content.get('copyVariants', []))
+ expression_variants: list[IprExprVariant] = preprocess_expression_variants(
content.get('expressionVariants', [])
)
@@ -459,7 +460,7 @@ def ipr_report(
if expression_variants:
check_comparators(content, expression_variants)
- genes_with_variants: Set[str] = check_variant_links(
+ genes_with_variants: set[str] = check_variant_links(
small_mutations, expression_variants, copy_variants, structural_variants
)
@@ -478,12 +479,12 @@ def ipr_report(
# Matching disease RIDs from GraphKB using term tree
# (Will raise uncatched error if no match)
- disease_match_records: list[Dict] = get_kb_disease_matches(graphkb_conn, kb_disease_match)
+ disease_match_records: list[dict] = get_kb_disease_matches(graphkb_conn, kb_disease_match)
disease_match_rids: list[str] = [item['@rid'] for item in disease_match_records]
disease_match_names: list[str] = [item['name'] for item in disease_match_records]
# GKB MATCHING (AKA ANNOTATION)
- gkb_matches: List[Hashabledict] = annotate_variants(
+ gkb_matches: list[Hashabledict] = annotate_variants(
graphkb_conn=graphkb_conn,
interactive=interactive,
disease_matches=disease_match_rids,
@@ -654,7 +655,13 @@ def ipr_report(
output, mins_to_wait, async_upload, ignore_extra_fields
)
logger.info(ipr_result)
- output.update(ipr_result)
+ if async_upload and 'report' in ipr_result:
+ # async uploads return the report nested under 'report' alongside
+ # job-tracking fields (eg. 'ident' of the job, 'jobStatus'); unwrap it
+ # so the merged shape matches the non-async (sync) upload result.
+ output['ident'] = ipr_result['report']['ident']
+ else:
+ output.update(ipr_result)
except Exception as err:
upload_error = err
logger.error(f'ipr_conn.upload_report failed: {err}', exc_info=True)
From ff5234fe9fb6ef031854ba982bfe2e5e7a7bb463 Mon Sep 17 00:00:00 2001
From: Eleanor Lewis
Date: Wed, 16 Sep 2026 15:22:05 -0700
Subject: [PATCH 41/57] add comment
---
pori_python/ipr/main.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/pori_python/ipr/main.py b/pori_python/ipr/main.py
index 48b8504d..f4cafffa 100644
--- a/pori_python/ipr/main.py
+++ b/pori_python/ipr/main.py
@@ -657,10 +657,13 @@ def ipr_report(
logger.info(ipr_result)
if async_upload and 'report' in ipr_result:
# async uploads return the report nested under 'report' alongside
- # job-tracking fields (eg. 'ident' of the job, 'jobStatus'); unwrap it
- # so the merged shape matches the non-async (sync) upload result.
+ # job-tracking fields (eg. 'ident' of the job, 'jobStatus')
+ # full contents of 'report' aren't needed and can be ignored.
+ # no 'message' value is available for async results
+ # but 'ident' is, and is required by users.
output['ident'] = ipr_result['report']['ident']
else:
+ # for sync uploads, add 'ident' and 'message' fields to the output.
output.update(ipr_result)
except Exception as err:
upload_error = err
From 2c59b130689fb2722ca444374b4998dca85733f7 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Wed, 16 Sep 2026 15:46:26 -0700
Subject: [PATCH 42/57] - DEVSU-2990 - Make variant text header font size
consistent - Add space break after comma of each cancer type in variant text
header
---
pori_python/ipr/summary.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pori_python/ipr/summary.py b/pori_python/ipr/summary.py
index 6038eb07..05025c2d 100644
--- a/pori_python/ipr/summary.py
+++ b/pori_python/ipr/summary.py
@@ -372,11 +372,11 @@ def prep_single_ipr_variant_comment(variant_text):
Returns:
section: html-formatted string
"""
- cancer_type = ','.join(variant_text['cancerType'])
+ cancer_type = ', '.join(variant_text['cancerType'])
if not cancer_type:
cancer_type = 'no specific cancer types'
cancer_type = f' ({cancer_type})'
- section = [f'{variant_text["variantName"]}{cancer_type}
']
+ section = [f'{variant_text["variantName"]}{cancer_type}
']
section.append(f'{variant_text["text"]}
')
return section
From c47359cec451556cb95a1f1eeb1a8699e2ee4308 Mon Sep 17 00:00:00 2001
From: bnguyen-bcgsc
Date: Thu, 17 Sep 2026 08:55:16 -0700
Subject: [PATCH 43/57] - Update asserted mock variant text summary lines in
test summary
---
tests/test_ipr/test_summary.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_ipr/test_summary.py b/tests/test_ipr/test_summary.py
index a2f0009f..c3e128f6 100644
--- a/tests/test_ipr/test_summary.py
+++ b/tests/test_ipr/test_summary.py
@@ -218,7 +218,7 @@ def test_gets_fully_matched_output_when_possible(self):
include_nonspecific_template=True,
)
summary_lines = ipr_summary.split('\n')
- assert summary_lines[1] == 'ERBB2 amplification (test1,test)
'
+ assert summary_lines[1] == 'ERBB2 amplification (test1, test)
'
assert summary_lines[2] == 'normal
'
assert len(summary_lines) == 3
@@ -321,7 +321,7 @@ def test_includes_nonspecific_disease_matches_when_specified(self):
include_nonspecific_template=False,
)
summary_lines = ipr_summary.split('\n')
- assert summary_lines[1] == 'ERBB2 amplification (no specific cancer types)
'
+ assert summary_lines[1] == 'ERBB2 amplification (no specific cancer types)
'
assert summary_lines[2] == 'no cancerType
'
assert len(summary_lines) == 3
@@ -340,7 +340,7 @@ def test_includes_all_graphkb_disease_matches(self):
include_nonspecific_template=False,
)
summary_lines = ipr_summary.split('\n')
- assert summary_lines[1] == 'ERBB2 amplification (test1,test)
'
+ assert summary_lines[1] == 'ERBB2 amplification (test1, test)
'
assert summary_lines[2] == 'normal
'
assert len(summary_lines) == 3
@@ -363,7 +363,7 @@ def test_prepare_section_for_multiple_variants(self):
assert len(summary_lines) == 5
assert (
'\n'.join(summary_lines[1:])
- == 'ERBB2 amplification (test1,test)
\nnormal
\nsecond variant (test1,test)
\nnormal, second variant
'
+ == 'ERBB2 amplification (test1, test)
\nnormal
\nsecond variant (test1, test)
\nnormal, second variant
'
)
def test_empty_section_when_no_variant_match(self):
From 883153ce41277d2ccec565a0e8ce88d2acbb3c6e Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 21 Sep 2026 12:31:27 -0700
Subject: [PATCH 44/57] only warn about turning off limiting if its not already
off
---
pori_python/graphkb/util.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index f0b92e6f..1f82d2d0 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -137,10 +137,11 @@ def __init__(
session_kwargs['cache_control'] = True
if 'PYTEST_CURRENT_TEST' in os.environ or only_if_cached:
- logging.warning(
- f'rate limiting is by default turned off for tests and cache-only queries'
- )
- limiter = None
+ if limiter is not None:
+ logging.warning(
+ 'rate limiting is by default turned off for tests and cache-only queries. Setting limiter to None'
+ )
+ limiter = None
if not session:
self.http = session_cls(**session_kwargs)
From 71b22485907d2ef5f2994156a96b29bef34fa01b Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 21 Sep 2026 16:32:55 -0700
Subject: [PATCH 45/57] Make return properties on basic vocab functions
configurable
---
pori_python/graphkb/vocab.py | 19 +++++++++----------
1 file changed, 9 insertions(+), 10 deletions(-)
diff --git a/pori_python/graphkb/vocab.py b/pori_python/graphkb/vocab.py
index bb96e5f5..162371ee 100644
--- a/pori_python/graphkb/vocab.py
+++ b/pori_python/graphkb/vocab.py
@@ -1,10 +1,12 @@
-from typing import Callable, Dict, Iterable, List, Set, cast, Union
+from typing import Callable, Dict, Iterable, List, Set, Union, cast
from pori_python.types import Ontology
from . import GraphKBConnection
from .util import convert_to_rid_list
+DEFAULT_RETURN_PROPERTIES = ['sourceId', 'sourceIdVersion', 'deprecated', 'name', '@rid']
+
def query_by_name(ontology_class: str, base_term_name: Union[str, list[str]]) -> Dict:
return {'target': ontology_class, 'filters': {'name': base_term_name}}
@@ -17,6 +19,7 @@ def get_equivalent_terms(
ontology_class: str = 'Vocabulary',
ignore_cache: bool = False,
build_base_query: Callable = query_by_name,
+ return_properties=DEFAULT_RETURN_PROPERTIES,
) -> List[Ontology]:
"""
Get a list of terms equivalent to the current term up to the root term
@@ -35,7 +38,7 @@ def get_equivalent_terms(
'target': {'target': base_records, 'queryType': 'descendants'},
'queryType': 'similarTo',
'treeEdges': [],
- 'returnProperties': ['sourceId', 'sourceIdVersion', 'deprecated', 'name', '@rid'],
+ 'returnProperties': return_properties,
},
ignore_cache=ignore_cache,
),
@@ -54,13 +57,7 @@ def get_equivalent_terms(
'target': {'target': root_records, 'queryType': 'descendants'},
'queryType': 'similarTo',
'treeEdges': [],
- 'returnProperties': [
- 'sourceId',
- 'sourceIdVersion',
- 'deprecated',
- 'name',
- '@rid',
- ],
+ 'returnProperties': ['@rid'],
},
ignore_cache=ignore_cache,
)
@@ -78,6 +75,7 @@ def get_term_tree(
include_superclasses: bool = True,
ignore_cache: bool = False,
build_base_query: Callable = query_by_name,
+ return_properties=DEFAULT_RETURN_PROPERTIES,
) -> List[Ontology]:
"""
Get terms equivalent to the base term by traversing the subclassOf tree and expanding related
@@ -105,7 +103,7 @@ def get_term_tree(
'target': {'target': base_records, 'queryType': 'ancestors'},
'queryType': 'similarTo',
'treeEdges': [],
- 'returnProperties': ['sourceId', 'sourceIdVersion', 'deprecated', 'name', '@rid'],
+ 'returnProperties': return_properties,
},
ignore_cache=ignore_cache,
),
@@ -119,6 +117,7 @@ def get_term_tree(
ontology_class=ontology_class,
ignore_cache=ignore_cache,
build_base_query=build_base_query,
+ return_properties=return_properties,
)
else:
parent_terms = []
From 941fc1f34f2445726e702aa41448e5793194927e Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 21 Sep 2026 17:10:00 -0700
Subject: [PATCH 46/57] Limiter off by default
---
pori_python/graphkb/util.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 1f82d2d0..55543292 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -98,7 +98,7 @@ def __init__(
cache_name: str = '',
only_if_cached: bool = False,
session: Optional[requests.Session] = None,
- limiter: LimiterAdapter = DEFAULT_LIMITER,
+ limiter: LimiterAdapter | None = None,
**session_kwargs,
):
"""
@@ -111,7 +111,7 @@ def __init__(
"""
session_cls = requests.Session
if limiter and not use_global_cache:
- raise NotImplementedError(f'currently rate limiting by default also implements caching')
+ raise NotImplementedError('currently rate limiting by default also implements caching')
if session is not None:
if limiter is not None:
raise NotImplementedError('cannot add limiter to an existing session')
From 77bd683322b8c7254ec04ef189639c3491df0312 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Mon, 21 Sep 2026 17:13:38 -0700
Subject: [PATCH 47/57] use python 3.9 compatible type hints
---
pori_python/graphkb/util.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 55543292..2f6a585e 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -98,7 +98,7 @@ def __init__(
cache_name: str = '',
only_if_cached: bool = False,
session: Optional[requests.Session] = None,
- limiter: LimiterAdapter | None = None,
+ limiter: Optional[LimiterAdapter] = None,
**session_kwargs,
):
"""
From c375f6c40ff7d61d446876e57fcdd6f87969f76d Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Tue, 22 Sep 2026 14:54:01 -0700
Subject: [PATCH 48/57] Fix adapter mount overrwrite. Cannot mount to same
prefix without overriting adapters. New code ensure the adapters are merged
if both are present before mounting them
---
pori_python/graphkb/util.py | 30 ++++++++++++++++--------------
1 file changed, 16 insertions(+), 14 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 2f6a585e..5398f16b 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -98,7 +98,7 @@ def __init__(
cache_name: str = '',
only_if_cached: bool = False,
session: Optional[requests.Session] = None,
- limiter: Optional[LimiterAdapter] = None,
+ limiter_kwargs: Optional[dict] = None,
**session_kwargs,
):
"""
@@ -110,10 +110,10 @@ def __init__(
- only_if_cached: this will set the cache-control header for all requests to only-if-cached which will raise 504 errors if a request does not exist in the cache already rather than making a new network request
"""
session_cls = requests.Session
- if limiter and not use_global_cache:
+ if limiter_kwargs and not use_global_cache:
raise NotImplementedError('currently rate limiting by default also implements caching')
if session is not None:
- if limiter is not None:
+ if limiter_kwargs is not None:
raise NotImplementedError('cannot add limiter to an existing session')
if use_global_cache:
raise NotImplementedError(
@@ -137,32 +137,34 @@ def __init__(
session_kwargs['cache_control'] = True
if 'PYTEST_CURRENT_TEST' in os.environ or only_if_cached:
- if limiter is not None:
+ if limiter_kwargs is not None:
logging.warning(
'rate limiting is by default turned off for tests and cache-only queries. Setting limiter to None'
)
- limiter = None
+ limiter_kwargs = None
if not session:
self.http = session_cls(**session_kwargs)
else:
self.http = session
- if limiter is not None:
- self.http.mount('http://', limiter)
- self.http.mount('https://', limiter)
-
- if not only_if_cached:
- # requests-cache returns 504 when something is not in cache, since we don't want to fetch networkx requests when this flag is set, retries are redundant
- retries = Retry(
+ adapter = HTTPAdapter(
+ max_retries=Retry(
total=100,
connect=5,
status=5,
backoff_factor=5,
status_forcelist=[429, 500, 502, 503, 504],
)
- self.http.mount('http://', HTTPAdapter(max_retries=retries))
- self.http.mount('https://', HTTPAdapter(max_retries=retries))
+ )
+
+ if limiter_kwargs:
+ adapter = LimiterAdapter(poolmanager=adapter.poolmanager, **limiter_kwargs)
+
+ if not only_if_cached:
+ # do not need adapters at all for cache-only hits
+ self.http.mount('http://', adapter)
+ self.http.mount('https://', adapter)
self.only_if_cached = only_if_cached
self.token = ''
From ec385e8833701b280ba47695f452282cd88df9b4 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Tue, 22 Sep 2026 14:57:57 -0700
Subject: [PATCH 49/57] Remove no-longer used features cache
---
pori_python/graphkb/match.py | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/pori_python/graphkb/match.py b/pori_python/graphkb/match.py
index 8fc16922..97a803ee 100644
--- a/pori_python/graphkb/match.py
+++ b/pori_python/graphkb/match.py
@@ -2,7 +2,7 @@
Functions which return Variants from GraphKB which match some input variant definition
"""
-from typing import Dict, List, Optional, Set, Union, cast
+from typing import Dict, List, Optional, Union, cast
from pori_python.types import (
BasicPosition,
@@ -33,8 +33,6 @@
)
from .vocab import get_equivalent_terms, get_term_by_name, get_term_tree, get_terms_set
-FEATURES_CACHE: Set[str] = set()
-
def get_equivalent_features(
conn: GraphKBConnection,
@@ -92,8 +90,6 @@ def get_equivalent_features(
filters.append(
{'OR': [{'sourceIdVersion': source_id_version}, {'sourceIdVersion': None}]}
)
- elif FEATURES_CACHE and gene_name.lower() not in FEATURES_CACHE and not ignore_cache:
- return []
else:
filters.append({'OR': [{'sourceId': gene_name}, {'name': gene_name}]})
From 36a63231dd743a836dffdaf9f20e6de5f46f9f94 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Sat, 26 Sep 2026 16:47:46 -0700
Subject: [PATCH 50/57] refresh request headers after relogin
---
pori_python/graphkb/util.py | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 5398f16b..d7049169 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -148,18 +148,18 @@ def __init__(
else:
self.http = session
- adapter = HTTPAdapter(
- max_retries=Retry(
- total=100,
- connect=5,
- status=5,
- backoff_factor=5,
- status_forcelist=[429, 500, 502, 503, 504],
- )
+ max_retries = Retry(
+ total=100,
+ connect=5,
+ status=5,
+ backoff_factor=5,
+ status_forcelist=[429, 500, 502, 503, 504],
)
if limiter_kwargs:
- adapter = LimiterAdapter(poolmanager=adapter.poolmanager, **limiter_kwargs)
+ adapter = LimiterAdapter(max_retries=max_retries, **limiter_kwargs)
+ else:
+ adapter = HTTPAdapter(max_retries=max_retries)
if not only_if_cached:
# do not need adapters at all for cache-only hits
@@ -254,6 +254,7 @@ def request(
try:
if need_refresh_login:
self.refresh_login()
+ request_headers.update(self.headers)
need_refresh_login = False
self.request_count += 1
From 753c160d72676e9d9b1b483452d855e03d9aac73 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Sat, 26 Sep 2026 16:50:32 -0700
Subject: [PATCH 51/57] Fix edge case of no cache but only_if_cached set
---
pori_python/graphkb/util.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index d7049169..160dcb64 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -125,6 +125,10 @@ def __init__(
)
if not use_global_cache and cache_name:
raise NotImplementedError('cache_name only applies when use_global_cache is True')
+ if only_if_cached and not use_global_cache:
+ raise NotImplementedError(
+ 'only_if_cached=True and use_global_cache=False are conflicting settings'
+ )
if use_global_cache:
session_cls = CachedSession
From a430ea498d2253733e1e978ffc72e9e65707f711 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Sat, 26 Sep 2026 17:00:57 -0700
Subject: [PATCH 52/57] Use a dynamic cache filter function so that we only
cache /query POST requests
---
pori_python/graphkb/util.py | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 160dcb64..55bef763 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -88,6 +88,19 @@ def millis_interval(start: datetime, end: datetime) -> int:
return millis
+def cache_filter(response):
+ """
+ Only cache POST requests when they are for the /query endpoint. Otherwise they are creating content
+ and should not be cached
+ """
+ request = response.request
+
+ if request.method == 'POST':
+ return request.url.rstrip('/').endswith('/query')
+
+ return True
+
+
class GraphKBConnection:
def __init__(
self,
@@ -137,7 +150,7 @@ def __init__(
else:
session_kwargs['cache_name'] = cache_name
session_kwargs['allowable_methods'] = ['GET', 'POST']
- session_kwargs['ignored_parameters'] = ['Authorization']
+ session_kwargs['filter_fn'] = cache_filter
session_kwargs['cache_control'] = True
if 'PYTEST_CURRENT_TEST' in os.environ or only_if_cached:
From 60cb2aab7addcca2639a40a48e3f933a23616e56 Mon Sep 17 00:00:00 2001
From: Caralyn Reisle
Date: Sun, 27 Sep 2026 12:41:45 -0700
Subject: [PATCH 53/57] can have limiter w/o caching for scripts creating
content
---
pori_python/graphkb/util.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/pori_python/graphkb/util.py b/pori_python/graphkb/util.py
index 55bef763..b92c85b2 100644
--- a/pori_python/graphkb/util.py
+++ b/pori_python/graphkb/util.py
@@ -123,8 +123,6 @@ def __init__(
- only_if_cached: this will set the cache-control header for all requests to only-if-cached which will raise 504 errors if a request does not exist in the cache already rather than making a new network request
"""
session_cls = requests.Session
- if limiter_kwargs and not use_global_cache:
- raise NotImplementedError('currently rate limiting by default also implements caching')
if session is not None:
if limiter_kwargs is not None:
raise NotImplementedError('cannot add limiter to an existing session')
From 04832c73dbbfe7f94f8f978bb0c3e45da6e88712 Mon Sep 17 00:00:00 2001
From: Eleanor Lewis
Date: Mon, 28 Sep 2026 09:46:56 -0700
Subject: [PATCH 54/57] version bump
---
setup.cfg | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/setup.cfg b/setup.cfg
index c81caa50..9388721f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -18,7 +18,7 @@ known_standard_library = requests
[metadata]
name = pori_python
-version = 1.5.0
+version = 1.6.0
url = https://github.com/bcgsc/pori_python
author_email = dat@bcgsc.ca
maintainer_email = dat@bcgsc.ca
From 35caa8936dc44b7b64914441100d3ce32eaba18e Mon Sep 17 00:00:00 2001
From: Eleanor Lewis
Date: Tue, 29 Sep 2026 14:07:39 -0700
Subject: [PATCH 55/57] use requests-cache without extra
---
setup.cfg | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/setup.cfg b/setup.cfg
index c81caa50..b1f2c337 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -36,7 +36,7 @@ install_requires =
requests
tqdm
typing_extensions>=3.7.4.2,<5
- requests-cache[sqlite]
+ requests-cache==1.3.3
requests-ratelimiter
pyrate-limiter
From 1f9cce139f313d4a80ca1e692b5aadc557e61805 Mon Sep 17 00:00:00 2001
From: Eleanor Lewis
Date: Tue, 29 Sep 2026 14:47:00 -0700
Subject: [PATCH 56/57] add cache and rate limit tests
---
tests/test_graphkb/test_util.py | 204 +++++++++++++++++++++++++++++++-
1 file changed, 202 insertions(+), 2 deletions(-)
diff --git a/tests/test_graphkb/test_util.py b/tests/test_graphkb/test_util.py
index dbbb2c2b..f7637bfe 100644
--- a/tests/test_graphkb/test_util.py
+++ b/tests/test_graphkb/test_util.py
@@ -1,12 +1,42 @@
+import io
+import json
import os
-import pytest
import re
+import pytest
+import requests
+import urllib3
+from requests.adapters import HTTPAdapter
+from requests_ratelimiter import LimiterAdapter
+
from pori_python.graphkb import GraphKBConnection, util
EXCLUDE_BCGSC_TESTS = os.environ.get('EXCLUDE_BCGSC_TESTS') == '1'
+class CountingAdapter(HTTPAdapter):
+ """Test transport adapter that returns a canned JSON response without any
+ real network I/O, and counts how many times it was actually invoked (i.e.
+ how many requests were NOT served from cache).
+ """
+
+ def __init__(self, *args, **kwargs):
+ self.calls = 0
+ super().__init__(*args, **kwargs)
+
+ def send(self, request, **kwargs):
+ self.calls += 1
+ body = json.dumps({'call': self.calls}).encode('utf-8')
+ raw = urllib3.HTTPResponse(
+ body=io.BytesIO(body),
+ status=200,
+ preload_content=False,
+ headers={},
+ request_url=request.url,
+ )
+ return self.build_response(request, raw)
+
+
class OntologyTerm:
def __init__(self, name, sourceId, displayName):
self.name = name
@@ -16,7 +46,7 @@ def __init__(self, name, sourceId, displayName):
@pytest.fixture(scope='module')
def conn() -> GraphKBConnection:
- conn = GraphKBConnection()
+ conn = GraphKBConnection(url=os.environ['GRAPHKB_URL'])
conn.login(os.environ['GRAPHKB_USER'], os.environ['GRAPHKB_PASS'])
return conn
@@ -191,3 +221,173 @@ def test_get_related_terms(self, conn):
subgraphType='parents',
)
assert 'cancer' in disease_terms
+
+
+class TestRateLimitingOptIn:
+ """Rate limiting is opt-in per-connection via the `limiter_kwargs` argument.
+
+ Passing a truthy `limiter_kwargs` dict (e.g. `{'per_second': 3}`) mounts a
+ `LimiterAdapter`; leaving it unset (None/default) or empty leaves the plain
+ `HTTPAdapter` in place, i.e. rate limiting disabled. Regardless of
+ `limiter_kwargs`, rate limiting is always forced off while running under
+ pytest (`PYTEST_CURRENT_TEST` set) or when `only_if_cached=True`.
+ """
+
+ def test_default_has_no_rate_limiting(self):
+ conn = GraphKBConnection(url='http://localhost:8080')
+ assert isinstance(conn.http.adapters['https://'], HTTPAdapter)
+ assert not isinstance(conn.http.adapters['https://'], LimiterAdapter)
+
+ def test_empty_limiter_kwargs_has_no_rate_limiting(self):
+ conn = GraphKBConnection(url='http://localhost:8080', limiter_kwargs={})
+ assert not isinstance(conn.http.adapters['https://'], LimiterAdapter)
+
+ def test_limiter_kwargs_forced_off_while_under_pytest(self, caplog):
+ # PYTEST_CURRENT_TEST is set while running under pytest, so rate
+ # limiting should stay disabled even when limiter_kwargs is provided.
+ assert 'PYTEST_CURRENT_TEST' in os.environ
+ conn = GraphKBConnection(url='http://localhost:8080', limiter_kwargs={'per_second': 3})
+ assert not isinstance(conn.http.adapters['https://'], LimiterAdapter)
+ assert 'rate limiting is by default turned off' in caplog.text
+
+ def test_limiter_kwargs_enables_rate_limiting_outside_pytest(self, monkeypatch):
+ monkeypatch.delenv('PYTEST_CURRENT_TEST', raising=False)
+ conn = GraphKBConnection(url='http://localhost:8080', limiter_kwargs={'per_second': 3})
+ assert isinstance(conn.http.adapters['https://'], LimiterAdapter)
+
+ def test_only_if_cached_forces_rate_limiting_off(self, monkeypatch, caplog):
+ monkeypatch.delenv('PYTEST_CURRENT_TEST', raising=False)
+ conn = GraphKBConnection(
+ url='http://localhost:8080', only_if_cached=True, limiter_kwargs={'per_second': 3}
+ )
+ assert conn.only_if_cached is True
+ assert 'rate limiting is by default turned off' in caplog.text
+
+ def test_limiter_kwargs_with_custom_session_raises(self):
+ with pytest.raises(NotImplementedError):
+ GraphKBConnection(
+ url='http://localhost:8080',
+ session=requests.Session(),
+ limiter_kwargs={'per_second': 3},
+ )
+
+
+class TestCacheFilter:
+ """Unit tests for util.cache_filter, which decides which responses requests-cache
+ is allowed to store: all GETs, but only POSTs to the /query endpoint (other POSTs
+ create content and must never be cached).
+ """
+
+ class _FakeRequest:
+ def __init__(self, method, url):
+ self.method = method
+ self.url = url
+
+ class _FakeResponse:
+ def __init__(self, method, url):
+ self.request = TestCacheFilter._FakeRequest(method, url)
+
+ def test_get_requests_are_always_cacheable(self):
+ response = self._FakeResponse('GET', 'http://fake/api/statement')
+ assert util.cache_filter(response) is True
+
+ @pytest.mark.parametrize('url', ['http://fake/api/query', 'http://fake/api/query/'])
+ def test_post_to_query_endpoint_is_cacheable(self, url):
+ response = self._FakeResponse('POST', url)
+ assert util.cache_filter(response) is True
+
+ @pytest.mark.parametrize('url', ['http://fake/api/statement', 'http://fake/api/query/similar'])
+ def test_post_to_other_endpoints_is_not_cacheable(self, url):
+ response = self._FakeResponse('POST', url)
+ assert util.cache_filter(response) is False
+
+
+class TestCachingBehavior:
+ """End-to-end tests that mount a counting fake adapter under GraphKBConnection's
+ session so we can assert whether a real request was made (adapter.calls
+ incremented) or the response was served from the requests-cache layer instead.
+ """
+
+ def test_repeated_get_is_served_from_cache(self):
+ conn = GraphKBConnection(url='http://fake-graphkb', use_global_cache=True)
+ adapter = CountingAdapter()
+ conn.http.mount('http://', adapter)
+
+ first = conn.http.get('http://fake-graphkb/api/version')
+ second = conn.http.get('http://fake-graphkb/api/version')
+
+ assert adapter.calls == 1
+ assert first.json() == second.json()
+ assert getattr(second, 'from_cache', False) is True
+
+ def test_no_cache_header_bypasses_cache(self):
+ conn = GraphKBConnection(url='http://fake-graphkb', use_global_cache=True)
+ adapter = CountingAdapter()
+ conn.http.mount('http://', adapter)
+
+ conn.http.get('http://fake-graphkb/api/version')
+ conn.http.get('http://fake-graphkb/api/version', headers={'Cache-Control': 'no-cache'})
+
+ assert adapter.calls == 2
+
+ def test_only_if_cached_returns_504_when_not_cached(self):
+ conn = GraphKBConnection(url='http://fake-graphkb', use_global_cache=True)
+ adapter = CountingAdapter()
+ conn.http.mount('http://', adapter)
+
+ resp = conn.http.get(
+ 'http://fake-graphkb/api/never-requested',
+ headers={'Cache-Control': 'only-if-cached'},
+ )
+
+ assert resp.status_code == 504
+ assert adapter.calls == 0
+
+ def test_only_if_cached_returns_cached_value_without_new_request(self):
+ conn = GraphKBConnection(url='http://fake-graphkb', use_global_cache=True)
+ adapter = CountingAdapter()
+ conn.http.mount('http://', adapter)
+
+ conn.http.get('http://fake-graphkb/api/version')
+ resp = conn.http.get(
+ 'http://fake-graphkb/api/version', headers={'Cache-Control': 'only-if-cached'}
+ )
+
+ assert resp.status_code == 200
+ assert adapter.calls == 1
+
+ def test_post_to_query_endpoint_is_cached(self):
+ conn = GraphKBConnection(url='http://fake-graphkb', use_global_cache=True)
+ adapter = CountingAdapter()
+ conn.http.mount('http://', adapter)
+
+ conn.http.post('http://fake-graphkb/api/query', data='{}')
+ conn.http.post('http://fake-graphkb/api/query', data='{}')
+
+ assert adapter.calls == 1
+
+ def test_post_to_non_query_endpoint_is_never_cached(self):
+ conn = GraphKBConnection(url='http://fake-graphkb', use_global_cache=True)
+ adapter = CountingAdapter()
+ conn.http.mount('http://', adapter)
+
+ conn.http.post('http://fake-graphkb/api/statement', data='{}')
+ conn.http.post('http://fake-graphkb/api/statement', data='{}')
+
+ assert adapter.calls == 2
+
+ def test_use_global_cache_false_hits_adapter_every_time(self):
+ conn = GraphKBConnection(url='http://fake-graphkb', use_global_cache=False)
+ adapter = CountingAdapter()
+ conn.http.mount('http://', adapter)
+
+ conn.http.get('http://fake-graphkb/api/version')
+ conn.http.get('http://fake-graphkb/api/version')
+
+ assert adapter.calls == 2
+
+ def test_use_global_cache_false_raises_with_cache_name(self):
+ with pytest.raises(NotImplementedError):
+ GraphKBConnection(
+ url='http://fake-graphkb', use_global_cache=False, cache_name='somewhere'
+ )
From cb69cd639f9d861b5dc27e6d889a5a998b0e9fa4 Mon Sep 17 00:00:00 2001
From: Eleanor Lewis
Date: Thu, 1 Oct 2026 14:08:00 -0700
Subject: [PATCH 57/57] more flexible requests-cache version range
---
setup.cfg | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/setup.cfg b/setup.cfg
index b1f2c337..14fe2837 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -36,7 +36,7 @@ install_requires =
requests
tqdm
typing_extensions>=3.7.4.2,<5
- requests-cache==1.3.3
+ requests-cache>=1.3.0,<2.0
requests-ratelimiter
pyrate-limiter