Skip to content

Commit 928584b

Browse files
committed
Skip tests on HTTP 403 from remote APIs
Add a `skip_on_forbidden` decorator to gracefully handle HTTP 403 Forbidden responses from remote APIs in CI environments where outbound requests may be blocked. This prevents environment-based failures from being reported as test failures. The decorator walks the exception chain to detect 403 responses across different API wrapper types, emits a warning, and skips the test rather than failing it. Applied to all remote test methods in test_composer.py and test_fetcher.py.
1 parent d839ee9 commit 928584b

3 files changed

Lines changed: 76 additions & 0 deletions

File tree

tests/integration/test_composer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import pytest
22
import pyenzyme as pe
33
from pyenzyme.tools import to_dict_wo_json_ld
4+
from tests.utils import skip_on_forbidden
45

56

67
class TestComposer:
8+
@skip_on_forbidden
79
def test_compose(self):
810
# Act
911
doc = pe.compose(
@@ -23,6 +25,7 @@ def test_compose(self):
2325
expected_doc = pe.read_enzymeml("tests/fixtures/compose/expected_compose.json")
2426
assert to_dict_wo_json_ld(doc) == to_dict_wo_json_ld(expected_doc)
2527

28+
@skip_on_forbidden
2629
def test_compose_with_prefix(self):
2730
# Act
2831
doc = pe.compose(
@@ -42,6 +45,7 @@ def test_compose_with_prefix(self):
4245
expected_doc = pe.read_enzymeml("tests/fixtures/compose/expected_compose.json")
4346
assert to_dict_wo_json_ld(doc) == to_dict_wo_json_ld(expected_doc)
4447

48+
@skip_on_forbidden
4549
def test_compose_no_vessel(self):
4650
# Act
4751
doc = pe.compose(

tests/integration/test_fetcher.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from pyenzyme.fetcher.pubchem import fetch_pubchem
77
from pyenzyme.fetcher.rhea import fetch_rhea
88
from pyenzyme.fetcher.uniprot import fetch_uniprot
9+
from tests.utils import skip_on_forbidden
910

1011

1112
class TestFetcher:
1213
@pytest.mark.remote
14+
@skip_on_forbidden
1315
def test_fetch_chebi_to_small_molecule(self):
1416
small_molecule = fetch_chebi("CHEBI:15377")
1517
assert small_molecule is not None
@@ -26,6 +28,7 @@ def test_fetch_chebi_to_small_molecule(self):
2628
)
2729

2830
@pytest.mark.remote
31+
@skip_on_forbidden
2932
def test_fetch_chebi_to_small_molecule_with_id(self):
3033
small_molecule = fetch_chebi("CHEBI:15377", smallmol_id="s1")
3134
assert small_molecule is not None
@@ -42,11 +45,13 @@ def test_fetch_chebi_to_small_molecule_with_id(self):
4245
)
4346

4447
@pytest.mark.remote
48+
@skip_on_forbidden
4549
def test_fetch_chebi_to_small_molecule_invalid_id(self):
4650
with pytest.raises(ValueError):
4751
fetch_chebi("INVALID_ID")
4852

4953
@pytest.mark.remote
54+
@skip_on_forbidden
5055
def test_fetch_uniprot_to_protein(self):
5156
protein = fetch_uniprot("P07327")
5257
assert protein is not None
@@ -64,6 +69,7 @@ def test_fetch_uniprot_to_protein(self):
6469
assert protein.references[0] == "https://www.uniprot.org/uniprotkb/P07327"
6570

6671
@pytest.mark.remote
72+
@skip_on_forbidden
6773
def test_fetch_uniprot_to_protein_with_prefix(self):
6874
protein = fetch_uniprot("uniprot:P07327")
6975
assert protein is not None
@@ -81,6 +87,7 @@ def test_fetch_uniprot_to_protein_with_prefix(self):
8187
assert protein.references[0] == "https://www.uniprot.org/uniprotkb/P07327"
8288

8389
@pytest.mark.remote
90+
@skip_on_forbidden
8491
def test_fetch_uniprot_to_protein_with_id(self):
8592
protein = fetch_uniprot("P07327", protein_id="p1")
8693
assert protein is not None
@@ -98,6 +105,7 @@ def test_fetch_uniprot_to_protein_with_id(self):
98105
assert protein.references[0] == "https://www.uniprot.org/uniprotkb/P07327"
99106

100107
@pytest.mark.remote
108+
@skip_on_forbidden
101109
def test_fetch_rhea_to_reaction(self):
102110
reaction, small_molecules = fetch_rhea("RHEA:22864")
103111

@@ -124,11 +132,13 @@ def test_fetch_rhea_to_reaction(self):
124132
assert reaction.products[0].species_id == small_molecules[0].id
125133

126134
@pytest.mark.remote
135+
@skip_on_forbidden
127136
def test_fetch_rhea_to_reaction_invalid_id(self):
128137
with pytest.raises(ValueError):
129138
fetch_rhea("INVALID_ID")
130139

131140
@pytest.mark.remote
141+
@skip_on_forbidden
132142
def test_fetch_pubchem_to_small_molecule(self):
133143
small_molecule = fetch_pubchem(cid="2244")
134144
assert small_molecule is not None
@@ -142,6 +152,7 @@ def test_fetch_pubchem_to_small_molecule(self):
142152
)
143153

144154
@pytest.mark.remote
155+
@skip_on_forbidden
145156
def test_fetch_pubchem_to_small_molecule_with_prefix(self):
146157
small_molecule = fetch_pubchem(cid="pubchem:2244")
147158
assert small_molecule is not None
@@ -155,11 +166,13 @@ def test_fetch_pubchem_to_small_molecule_with_prefix(self):
155166
)
156167

157168
@pytest.mark.remote
169+
@skip_on_forbidden
158170
def test_fetch_pubchem_to_small_molecule_invalid_id(self):
159171
with pytest.raises(httpx.HTTPStatusError):
160172
fetch_pubchem(cid="162176127617627")
161173

162174
@pytest.mark.remote
175+
@skip_on_forbidden
163176
def test_fetch_pdb_to_protein(self):
164177
protein = fetch_pdb("1a23")
165178
assert protein is not None
@@ -174,6 +187,7 @@ def test_fetch_pdb_to_protein(self):
174187
)
175188

176189
@pytest.mark.remote
190+
@skip_on_forbidden
177191
def test_fetch_pdb_to_protein_with_prefix(self):
178192
protein = fetch_pdb("pdb:1a23")
179193
assert protein is not None
@@ -188,6 +202,7 @@ def test_fetch_pdb_to_protein_with_prefix(self):
188202
)
189203

190204
@pytest.mark.remote
205+
@skip_on_forbidden
191206
def test_fetch_pdb_to_protein_invalid_id(self):
192207
with pytest.raises(ValueError):
193208
fetch_pdb("INVALID_ID")

tests/utils.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Test helpers shared across the integration suite."""
2+
3+
import functools
4+
import warnings
5+
6+
import pytest
7+
8+
9+
def _is_forbidden(exc: BaseException) -> bool:
10+
"""Return True if ``exc`` (or anything in its cause/context chain) represents
11+
an HTTP 403 Forbidden response.
12+
13+
The fetchers wrap remote errors in different ways (``httpx.HTTPStatusError``,
14+
``requests`` errors, ``ChEBIError``, ``ConnectionError``, ``ValueError`` …),
15+
so we walk the whole exception chain and check both the response status code
16+
and the string representation.
17+
"""
18+
seen: set[int] = set()
19+
while exc is not None and id(exc) not in seen:
20+
seen.add(id(exc))
21+
22+
response = getattr(exc, "response", None)
23+
if getattr(response, "status_code", None) == 403:
24+
return True
25+
26+
message = str(exc)
27+
if "403" in message or "Forbidden" in message:
28+
return True
29+
30+
exc = exc.__cause__ or exc.__context__
31+
32+
return False
33+
34+
35+
def skip_on_forbidden(func):
36+
"""Skip a remote test (with a warning) when the remote API returns HTTP 403.
37+
38+
Some databases block outbound requests from CI, which surfaces as a 403
39+
Forbidden. That is an environment restriction, not a genuine test failure,
40+
so we emit a warning and skip the test rather than fail it.
41+
"""
42+
43+
@functools.wraps(func)
44+
def wrapper(*args, **kwargs):
45+
try:
46+
return func(*args, **kwargs)
47+
except Exception as exc:
48+
if _is_forbidden(exc):
49+
warnings.warn(
50+
f"'{func.__name__}' received HTTP 403 Forbidden from a remote "
51+
f"API (likely blocked in this environment); skipping.",
52+
stacklevel=2,
53+
)
54+
pytest.skip(f"Remote API returned 403 Forbidden for {func.__name__}")
55+
raise
56+
57+
return wrapper

0 commit comments

Comments
 (0)