From 0a45db30db7985d8fad7a03325ed4cbededa1723 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 14 May 2026 12:51:42 -0700 Subject: [PATCH 1/2] geotiff tests: gate GPU and HTTP tests on runtime availability (#1872) Two test infrastructure gaps caused noisy failures in restricted CI: - test_cog.py::TestGPUCOGOverviews gated on import cupy alone, so a sandbox with cupy installed but no working CUDA runtime ran the tests instead of skipping. test_signature_annotations_1705.py had the same shape using importlib.util.find_spec. - HTTP tests stand up loopback servers with TCPServer(('127.0.0.1', 0)). Sandboxes that deny loopback bind error out instead of skipping. Add shared helpers in tests/conftest.py: - gpu_available() probes cupy.cuda.is_available(). - loopback_available() probes a real socket bind once at collection. - pytest_collection_modifyitems auto-skips any test module that imports socketserver when loopback is unavailable; every HTTP test in this directory does so to host its in-process server. The two GPU files now route their skipif through gpu_available(). --- xrspatial/geotiff/tests/conftest.py | 66 +++++++++++++++++++ xrspatial/geotiff/tests/test_cog.py | 10 ++- .../tests/test_signature_annotations_1705.py | 8 +-- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/xrspatial/geotiff/tests/conftest.py b/xrspatial/geotiff/tests/conftest.py index b90e96f35..b1b5d4308 100644 --- a/xrspatial/geotiff/tests/conftest.py +++ b/xrspatial/geotiff/tests/conftest.py @@ -1,13 +1,79 @@ """Shared fixtures for geotiff tests.""" from __future__ import annotations +import importlib.util import math +import socket import struct import numpy as np import pytest +def gpu_available() -> bool: + """True iff cupy imports AND a CUDA device is actually usable. + + Some sandboxes ship cupy without a working CUDA runtime. A bare + ``import cupy`` succeeds there but every device call fails, so test + files that gate on the import alone show false failures. + """ + if importlib.util.find_spec("cupy") is None: + return False + try: + import cupy + return bool(cupy.cuda.is_available()) + except Exception: + return False + + +def loopback_available() -> bool: + """True iff a loopback TCP bind succeeds on this host. + + Some sandboxed environments deny ``bind(('127.0.0.1', 0))``. HTTP + tests that stand up a loopback server should skip rather than error + in that case. + """ + try: + s = socket.socket() + try: + s.bind(('127.0.0.1', 0)) + finally: + s.close() + except OSError: + return False + return True + + +_HAS_GPU = gpu_available() +_HAS_LOOPBACK = loopback_available() + +requires_gpu = pytest.mark.skipif( + not _HAS_GPU, reason="cupy + CUDA required" +) +requires_loopback = pytest.mark.skipif( + not _HAS_LOOPBACK, reason="loopback bind unavailable in this environment" +) + + +def pytest_collection_modifyitems(config, items): + """Auto-skip tests that stand up a loopback HTTP server when the + sandbox denies socket bind. A test opts in implicitly by importing + ``socketserver`` in its module; every HTTP test in this directory + does so to host a tiny in-process server. + """ + if _HAS_LOOPBACK: + return + skip_marker = pytest.mark.skip( + reason="loopback bind unavailable in this environment" + ) + for item in items: + module = getattr(item, 'module', None) + if module is None: + continue + if 'socketserver' in vars(module): + item.add_marker(skip_marker) + + def make_minimal_tiff( width: int = 4, height: int = 4, diff --git a/xrspatial/geotiff/tests/test_cog.py b/xrspatial/geotiff/tests/test_cog.py index 0797784ed..e4f71fa44 100644 --- a/xrspatial/geotiff/tests/test_cog.py +++ b/xrspatial/geotiff/tests/test_cog.py @@ -277,14 +277,12 @@ def test_to_geotiff_cog_auto_overviews(self, tmp_path): assert len(ifds) >= 2 -try: - import cupy - _HAS_CUPY = True -except ImportError: - _HAS_CUPY = False +from .conftest import gpu_available +_HAS_GPU = gpu_available() -@pytest.mark.skipif(not _HAS_CUPY, reason="CuPy not installed") + +@pytest.mark.skipif(not _HAS_GPU, reason="cupy + CUDA required") class TestGPUCOGOverviews: """GPU-specific COG overview tests (require CuPy + CUDA).""" diff --git a/xrspatial/geotiff/tests/test_signature_annotations_1705.py b/xrspatial/geotiff/tests/test_signature_annotations_1705.py index f271ff500..45e015a3f 100644 --- a/xrspatial/geotiff/tests/test_signature_annotations_1705.py +++ b/xrspatial/geotiff/tests/test_signature_annotations_1705.py @@ -114,12 +114,12 @@ def test_write_geotiff_gpu_streaming_buffer_bytes_runtime_noop(tmp_path): """Passing an explicit ``streaming_buffer_bytes`` to the GPU writer must remain a no-op. The body still does ``del streaming_buffer_bytes`` so the value has no effect on the produced file.""" - import importlib.util + import pytest - if importlib.util.find_spec("cupy") is None: - import pytest + from .conftest import gpu_available - pytest.skip("cupy required for write_geotiff_gpu") + if not gpu_available(): + pytest.skip("cupy + CUDA required for write_geotiff_gpu") import cupy import numpy as np From bad8c7a6b8f666f40abc9024603de132574f6cf3 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 14 May 2026 13:52:13 -0700 Subject: [PATCH 2/2] geotiff tests: per-test loopback detection avoids over-skip in mixed files The module-level check skipped every test in a file that imports socketserver. In files mixing HTTP and non-HTTP tests (test_miniswhite_backend_parity_1797.py, test_cog_http_concurrent.py, test_http_meta_buffer_1718.py) that suppressed coverage that does not need loopback at all -- a GPU smoke test, four direct read_ranges tests, and three in-memory metadata-buffer tests. Replace the module-level check with per-test source inspection: a test needs loopback iff its body or any fixture in its closure references ``socketserver.TCPServer(`` or ``_serve(``. --- xrspatial/geotiff/tests/conftest.py | 50 +++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/xrspatial/geotiff/tests/conftest.py b/xrspatial/geotiff/tests/conftest.py index b1b5d4308..394d368af 100644 --- a/xrspatial/geotiff/tests/conftest.py +++ b/xrspatial/geotiff/tests/conftest.py @@ -57,20 +57,56 @@ def loopback_available() -> bool: def pytest_collection_modifyitems(config, items): """Auto-skip tests that stand up a loopback HTTP server when the - sandbox denies socket bind. A test opts in implicitly by importing - ``socketserver`` in its module; every HTTP test in this directory - does so to host a tiny in-process server. + sandbox denies socket bind. + + A test needs loopback iff its function body or any fixture in its + closure references ``socketserver.TCPServer(`` or invokes the + file-local ``_serve(`` helper. This is finer-grained than skipping + every test in a module that imports ``socketserver``: mixed files + (e.g. ``test_miniswhite_backend_parity_1797.py`` has both HTTP and + a local-file GPU test) keep their non-HTTP coverage in restricted + sandboxes. """ if _HAS_LOOPBACK: return + + import inspect + + def _source_of(obj) -> str: + try: + return inspect.getsource(obj) + except (OSError, TypeError): + return '' + + def _references_loopback(src: str) -> bool: + return 'socketserver.TCPServer(' in src or '_serve(' in src + skip_marker = pytest.mark.skip( reason="loopback bind unavailable in this environment" ) for item in items: - module = getattr(item, 'module', None) - if module is None: - continue - if 'socketserver' in vars(module): + needs_skip = False + + func = getattr(item, 'function', None) + if func is not None and _references_loopback(_source_of(func)): + needs_skip = True + + if not needs_skip: + fixtureinfo = getattr(item, '_fixtureinfo', None) + if fixtureinfo is not None: + name2defs = getattr(fixtureinfo, 'name2fixturedefs', {}) + for fname in getattr(fixtureinfo, 'names_closure', ()): + for fdef in name2defs.get(fname, ()): + ffunc = getattr(fdef, 'func', None) + if ffunc is not None and _references_loopback( + _source_of(ffunc) + ): + needs_skip = True + break + if needs_skip: + break + + if needs_skip: item.add_marker(skip_marker)