@@ -153,31 +153,65 @@ class TestToGeotiffGpuDispatcherFailClosed:
153153 dropping the forward (e.g. an accidental kwarg-rename or a missed
154154 keyword in a refactor) would silently bypass the validator while
155155 looking like everything still works.
156+
157+ The dispatcher also catches ``ImportError`` from ``write_geotiff_gpu``
158+ at ``_writers/eager.py:450`` and falls back to the CPU writer with a
159+ ``GeoTIFFFallbackWarning``. ``pytest.warns(Warning)`` swallows that
160+ fallback warning, so a silent CPU fallback would let these tests pass
161+ while never executing the GPU path under audit. Each test installs a
162+ spy on the module-level ``write_geotiff_gpu`` symbol that the
163+ dispatcher calls, and asserts the spy fired.
156164 """
157165
158- def test_dispatcher_garbage_raises_with_cupy_input (self , tmp_path ):
166+ @staticmethod
167+ def _install_gpu_spy (monkeypatch ):
168+ """Wrap ``write_geotiff_gpu`` so the dispatcher entry is recorded."""
169+ from xrspatial .geotiff ._writers import eager as _eager
170+
171+ real = _eager .write_geotiff_gpu
172+ calls = []
173+
174+ def _spy (* args , ** kwargs ):
175+ calls .append ((args , kwargs ))
176+ return real (* args , ** kwargs )
177+
178+ monkeypatch .setattr (_eager , "write_geotiff_gpu" , _spy )
179+ return calls
180+
181+ def test_dispatcher_garbage_raises_with_cupy_input (
182+ self , tmp_path , monkeypatch ):
159183 """CuPy-backed input auto-routes to the GPU writer."""
160184 from xrspatial .geotiff import to_geotiff
161185
186+ calls = self ._install_gpu_spy (monkeypatch )
162187 out = str (tmp_path / "dispatcher_garbage_gpu_1929.tif" )
163188 with pytest .warns (Warning ):
164189 with pytest .raises (ValueError , match = "GTCitationGeoKey" ):
165190 to_geotiff (_make_gpu_da (), out , crs = "absolute-garbage" )
191+ assert calls , (
192+ "dispatcher silently fell back to CPU; GPU path never entered"
193+ )
166194
167- def test_dispatcher_gpu_kwarg_garbage_raises (self , tmp_path ):
195+ def test_dispatcher_gpu_kwarg_garbage_raises (
196+ self , tmp_path , monkeypatch ):
168197 """Explicit ``gpu=True`` on numpy data also threads through."""
169198 from xrspatial .geotiff import to_geotiff
170199
200+ calls = self ._install_gpu_spy (monkeypatch )
171201 out = str (tmp_path / "dispatcher_gpu_kwarg_1929.tif" )
172202 with pytest .warns (Warning ):
173203 with pytest .raises (ValueError , match = "GTCitationGeoKey" ):
174204 to_geotiff (
175205 _make_cpu_da (), out , gpu = True , crs = "absolute-garbage" )
206+ assert calls , (
207+ "dispatcher silently fell back to CPU; GPU path never entered"
208+ )
176209
177- def test_dispatcher_opt_in_forwarded (self , tmp_path ):
210+ def test_dispatcher_opt_in_forwarded (self , tmp_path , monkeypatch ):
178211 """``allow_unparseable_crs=True`` is forwarded into the GPU writer."""
179212 from xrspatial .geotiff import to_geotiff
180213
214+ calls = self ._install_gpu_spy (monkeypatch )
181215 out = str (tmp_path / "dispatcher_optin_gpu_1929.tif" )
182216 with pytest .warns (Warning ):
183217 to_geotiff (
@@ -187,11 +221,17 @@ def test_dispatcher_opt_in_forwarded(self, tmp_path):
187221 allow_unparseable_crs = True ,
188222 )
189223 assert os .path .exists (out )
224+ assert calls , (
225+ "dispatcher silently fell back to CPU; GPU path never entered"
226+ )
227+ # Confirm the kwarg actually reached the GPU writer.
228+ assert calls [0 ][1 ].get ("allow_unparseable_crs" ) is True
190229
191- def test_dispatcher_opt_in_explicit_gpu (self , tmp_path ):
230+ def test_dispatcher_opt_in_explicit_gpu (self , tmp_path , monkeypatch ):
192231 """``to_geotiff(gpu=True, allow_unparseable_crs=True)`` also works."""
193232 from xrspatial .geotiff import to_geotiff
194233
234+ calls = self ._install_gpu_spy (monkeypatch )
195235 out = str (tmp_path / "dispatcher_optin_gpu_explicit_1929.tif" )
196236 with pytest .warns (Warning ):
197237 to_geotiff (
@@ -202,14 +242,23 @@ def test_dispatcher_opt_in_explicit_gpu(self, tmp_path):
202242 allow_unparseable_crs = True ,
203243 )
204244 assert os .path .exists (out )
245+ assert calls , (
246+ "dispatcher silently fell back to CPU; GPU path never entered"
247+ )
248+ assert calls [0 ][1 ].get ("allow_unparseable_crs" ) is True
205249
206250
207251class TestErrorMessageParity :
208- """The GPU and eager error messages match for the same input .
252+ """The GPU and eager error messages share the recovery hints .
209253
210254 A user catching ``ValueError`` from ``to_geotiff`` should see the
211- same message whether the backend is CPU or GPU. A cross-backend
212- drift would force callers to special-case the error string.
255+ same recovery guidance whether the backend is CPU or GPU. A
256+ cross-backend drift would force callers to special-case the error
257+ string. Asserting equality on the full message is too brittle though
258+ (e.g. either backend may eventually prepend or append backend-specific
259+ context like the destination path or the offending kwarg name); pin
260+ the shared recovery substrings instead so the assertion fails for
261+ real drift but tolerates additive backend-specific context.
213262 """
214263
215264 def test_gpu_vs_cpu_message_matches (self , tmp_path ):
@@ -225,7 +274,29 @@ def test_gpu_vs_cpu_message_matches(self, tmp_path):
225274 with pytest .raises (ValueError ) as exc_gpu :
226275 write_geotiff_gpu (
227276 _make_gpu_da (), out_gpu , crs = "absolute-garbage" )
228- assert str (exc_cpu .value ) == str (exc_gpu .value )
277+
278+ msg_cpu = str (exc_cpu .value )
279+ msg_gpu = str (exc_gpu .value )
280+ # Recovery hints emitted by ``_validate_crs_fallback`` for #1929.
281+ # Both backends route through the same helper, so each keyword
282+ # must appear in both messages.
283+ recovery_keywords = (
284+ "GTCitationGeoKey" ,
285+ "EPSG" ,
286+ "WKT" ,
287+ "pyproj" ,
288+ "allow_unparseable_crs" ,
289+ "absolute-garbage" ,
290+ )
291+ for token in recovery_keywords :
292+ assert token in msg_cpu , (
293+ f"CPU error message missing recovery keyword { token !r} : "
294+ f"{ msg_cpu !r} "
295+ )
296+ assert token in msg_gpu , (
297+ f"GPU error message missing recovery keyword { token !r} : "
298+ f"{ msg_gpu !r} "
299+ )
229300
230301
231302class TestKwargDefaultParity :
@@ -240,8 +311,14 @@ def test_default_is_false_on_all_writers(self):
240311 import inspect
241312
242313 from xrspatial .geotiff import to_geotiff , write_geotiff_gpu
243-
244- for fn in (to_geotiff , write_geotiff_gpu ):
314+ # ``_write_vrt_tiled`` is the third writer that consumes
315+ # ``allow_unparseable_crs`` and routes through
316+ # ``_validate_crs_fallback`` (eager.py:858). It is module-private
317+ # so import it directly from the writers module rather than from
318+ # the public ``xrspatial.geotiff`` namespace.
319+ from xrspatial .geotiff ._writers .eager import _write_vrt_tiled
320+
321+ for fn in (to_geotiff , write_geotiff_gpu , _write_vrt_tiled ):
245322 sig = inspect .signature (fn )
246323 param = sig .parameters .get ("allow_unparseable_crs" )
247324 assert param is not None , (
0 commit comments