Skip to content

geotiff: write_vrt's crs_wkt kwarg drifts from to_geotiff/write_geotiff_gpu's crs #1715

@brendancol

Description

@brendancol

Summary

The three public writer entry points in xrspatial.geotiff disagree on the CRS kwarg name and on the accepted input forms:

def to_geotiff(data, path, *, crs: int | str | None = None, ...):
    """crs : int, str, or None
        EPSG code (int), WKT string, or PROJ string. ...
    """

def write_geotiff_gpu(data, path, *, crs: int | str | None = None, ...):
    """crs : int, str, or None
        EPSG code or WKT string.
    """

def write_vrt(vrt_path, source_files, *, relative=True,
              crs_wkt: str | None = None, ...):
    """crs_wkt : str or None
        CRS as a WKT string. If None, the CRS is taken from the first
        source GeoTIFF.
    """

The reader entry points (open_geotiff, read_geotiff_dask, read_geotiff_gpu, read_vrt) surface the file's CRS as attrs['crs'] (an int EPSG when available) with the WKT fallback under attrs['crs_wkt']. So a round-trip pattern like

da = open_geotiff('in.tif')
to_geotiff(da, 'out.tif', crs=4326)             # works
write_geotiff_gpu(da, 'out.tif', crs=4326)      # works
write_vrt('out.vrt', [...], crs=4326)           # TypeError: unexpected keyword 'crs'

forces the caller to remember which writer uses which kwarg and to convert EPSG int -> WKT manually for the VRT writer only. This is a real Cat 1 surface-drift finding: same concept (the output CRS), three writers, two distinct kwarg names.

The asymmetry also makes write_vrt impossible to call uniformly from generic write-wrapper code: code that forwards crs=<value> to whichever writer matches the output extension has to special-case VRT.

Severity

MEDIUM (Cat 1, parameter naming drift). Not a correctness bug; the workaround is one line of pyproj. But it surprises users and makes IDE autocomplete inconsistent across the writer trio.

Proposed fix

Add a new crs kwarg on write_vrt mirroring to_geotiff/write_geotiff_gpu. Keep crs_wkt for backward compatibility with a DeprecationWarning. Reject passing both.

def write_vrt(vrt_path, source_files, *, relative=True,
              crs: int | str | None = None,
              crs_wkt: str | None = None,  # deprecated alias
              nodata: float | int | None = None) -> str:
    if crs is not None and crs_wkt is not None:
        raise TypeError("write_vrt: pass either 'crs' or 'crs_wkt', not both")
    if crs_wkt is not None:
        warnings.warn(
            "write_vrt(..., crs_wkt=...) is deprecated; use crs=... instead. "
            "The kwarg was renamed for parity with to_geotiff and "
            "write_geotiff_gpu, which already accept 'crs' as either an int "
            "EPSG code or a WKT string.",
            DeprecationWarning, stacklevel=2,
        )
        crs = crs_wkt

    # Resolve crs to a WKT string (or None) before forwarding
    resolved_wkt = _resolve_crs_to_wkt(crs)

    return _write_vrt_internal(
        vrt_path, source_files,
        relative=relative,
        crs_wkt=resolved_wkt,
        nodata=nodata,
    )

_resolve_crs_to_wkt accepts:

  • None -> returns None (no override; downstream picks CRS from the first source).
  • int -> pyproj.CRS.from_epsg(<int>).to_wkt().
  • str -> if it looks like an EPSG/AUTHORITY code, hand to pyproj; otherwise pass through as already-WKT or PROJ string. The wrapper can keep _wkt_to_epsg as its parser by inverting it: send the string through pyproj, then call to_wkt() on the result so PROJ-string callers get a normalized WKT.

Test plan:

  • Pin inspect.signature(write_vrt) exposes both crs and crs_wkt.
  • write_vrt(..., crs=4326) writes a VRT whose <SRS> element contains EPSG:4326's WKT.
  • write_vrt(..., crs_wkt='GEOGCS[...]') still works but emits DeprecationWarning.
  • Passing both crs= and crs_wkt= raises TypeError.
  • Read-back round trip: read_vrt(out_vrt).attrs['crs'] == 4326.

Non-breaking (deprecation shim; crs_wkt still works).

Discovered by

/sweep-api-consistency against the geotiff module on 2026-05-12.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions