Repository: rasterio/rasterio

Issue:
Add consistent parsing of GDAL version
Related to #1208 and other places where we need to modify behavior based on GDAL version.

We are using inconsistent methods to parse and enforce GDAL version.  We should centralize these into a single pattern.

For example, we are [parsing version components manually in `warp.py`](https://github.com/mapbox/rasterio/blob/master/rasterio/warp.py#L91-L94) whereas in our tests, we generally use `packaging.version.Version` which is only available as a development dependency.

One option would be to make `packaging` a regular requirement.  Alternatively, we could reimplement the `Version` class from there.

Or we could add more globals to the top of `__init__.py` (`__gdal_version__` is already defined as a string):

```
# Helpers for GDAL version numbers
__gdal_version_major_minor_patch__ = tuple(
    int(c) for c in __gdal_version__.split('.') if c.isdigit())[:3]
__gdal_major_version__ = __gdal_version_major_minor_patch__[0]
__gdal_minor_version__ = __gdal_version_major_minor_patch__[1]
```

/cc @sgillies 


Discussion:
@brendan-ward I've been shy about making packaging a runtime requirement, but my concern may not be warranted. We already require six, and rio-calc brings in pyparsing via snuggs.

No matter what, I'm 👍 on more consistency and the distutils version parser isn't flexible enough to handle some GDAL versions.

Resolving patch:
diff --git a/rasterio/env.py b/rasterio/env.py
--- a/rasterio/env.py
+++ b/rasterio/env.py
@@ -1,8 +1,11 @@
 """Rasterio's GDAL/AWS environment"""
 
-from functools import wraps
+from functools import wraps, total_ordering
 import logging
 import threading
+import re
+import attr
+
 
 import rasterio
 from rasterio._env import (
@@ -304,3 +307,58 @@ def wrapper(*args, **kwds):
             with Env():
                 return f(*args, **kwds)
         return wrapper
+
+
+@attr.s(slots=True)
+@total_ordering
+class GDALVersion(object):
+    """Convenience class for obtaining GDAL major and minor version components
+    and comparing between versions.  This is highly simplistic and assumes a
+    very normal numbering scheme for versions and ignores everything except
+    the major and minor components."""
+
+    major = attr.ib(default=0, validator=attr.validators.instance_of(int))
+    minor = attr.ib(default=0, validator=attr.validators.instance_of(int))
+
+    def __eq__(self, other):
+        return (self.major, self.minor) == tuple(other.major, other.minor)
+
+    def __lt__(self, other):
+        return (self.major, self.minor) < tuple(other.major, other.minor)
+
+    def __repr__(self):
+        return "GDALVersion(major={major}, minor={})".format(self)
+
+    @classmethod
+    def _normalize(cls, input):
+        """Normalize input tuple or string to GDALVersion"""
+
+        if isinstance(input, tuple):
+            return cls(*input)
+        elif isinstance(input, string_types):
+            return cls.from_string(input)
+        elif not isinstance(input, cls):
+            raise TypeError("Can only compare GDALVersion to same class, "
+                             "tuple, or string")
+        return input
+
+    @classmethod
+    def from_string(cls, s):
+        """Extract major and minor version components.
+        alpha, beta, rc suffixes ignored"""
+        match = re.search('^\d+\.\d+', s)
+        if not match:
+            raise ValueError("value does not appear to be a valid GDAL version "
+                             "number: {}".format(s))
+        major, minor = (int(c) for c in match.group().split('.'))
+        return cls(major=major, minor=minor)
+
+    @classmethod
+    def runtime(cls):
+        """Return GDALVersion of current GDAL runtime"""
+        from rasterio._base import gdal_version  # to avoid circular import
+        return cls.from_string(gdal_version())
+
+    def at_least(self, other):
+        other = self.__class__._normalize(other)
+        return self >= other
\ No newline at end of file
diff --git a/rasterio/warp.py b/rasterio/warp.py
--- a/rasterio/warp.py
+++ b/rasterio/warp.py
@@ -14,7 +14,7 @@
 from rasterio._warp import (
     _transform_geom, _reproject, _calculate_default_transform)
 from rasterio.enums import Resampling
-from rasterio.env import ensure_env
+from rasterio.env import ensure_env, GDALVersion
 from rasterio.errors import GDALBehaviorChangeException
 from rasterio.transform import guard_transform
 
@@ -88,11 +88,7 @@ def transform_geom(
         Transformed geometry in GeoJSON dict format
     """
 
-    loose_gdal_version = filter(
-        lambda x: x.isdigit(),
-        rasterio.__gdal_version__.split('.'))
-    loose_gdal_version = tuple(map(int, loose_gdal_version))
-    if loose_gdal_version[:2] >= (2, 2) and not antimeridian_cutting:
+    if (GDALVersion.runtime().at_least('2.2') and not antimeridian_cutting):
         raise GDALBehaviorChangeException(
             "Antimeridian cutting is always enabled on GDAL 2.2.0 or "
             "newer, which could produce a different geometry than expected.")


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -17,6 +17,7 @@
 import rasterio
 from rasterio.crs import CRS
 from rasterio.enums import ColorInterp
+from rasterio.env import GDALVersion
 
 
 DEFAULT_SHAPE = (10, 10)
@@ -494,3 +495,31 @@ def path_zip_file():
                              '389225main_sw_1965_1024.jpg']:
                 zip.write(os.path.join(data_dir(), filename), filename)
     return path
+
+
+# Define helpers to skip tests based on GDAL version
+gdal_version = GDALVersion.runtime()
+
+requires_only_gdal1 = pytest.mark.skipif(
+    gdal_version.major != 1,
+    reason="Only relevant for GDAL 1.x")
+
+requires_less_than_gdal110 = pytest.mark.skipif(
+    gdal_version.at_least('1.10'),
+    reason="Requires GDAL 1.9.x or earlier")
+
+requires_gdal110 = pytest.mark.skipif(
+    not gdal_version.at_least('1.10'),
+    reason="Requires GDAL 1.10.x")
+
+requires_gdal2 = pytest.mark.skipif(
+    not gdal_version.major >= 2,
+    reason="Requires GDAL 2.x")
+
+requires_gdal21 = pytest.mark.skipif(
+    not gdal_version.at_least('2.1'),
+    reason="Requires GDAL 2.1.x")
+
+requires_gdal22 = pytest.mark.skipif(
+    not gdal_version.at_least('2.2'),
+    reason="Requires GDAL 2.2.x")
\ No newline at end of file
diff --git a/tests/test_blocks.py b/tests/test_blocks.py
--- a/tests/test_blocks.py
+++ b/tests/test_blocks.py
@@ -1,20 +1,19 @@
 from functools import partial
-import logging
 import os.path
 import shutil
 import subprocess
-import sys
 import tempfile
 import unittest
 
 import numpy as np
-from packaging.version import parse
 import pytest
 
 import rasterio
 from rasterio import windows
 from rasterio.errors import RasterBlockError
 
+from .conftest import requires_gdal2
+
 
 class WindowTest(unittest.TestCase):
 
@@ -169,9 +168,7 @@ def test_block_windows_filtered_none(path_rgb_byte_tif):
             next(itr)
 
 
-@pytest.mark.skipif(
-    parse(rasterio.__gdal_version__) < parse('2.0.0'),
-    reason="TIFF block size access requires GDAL 2.0")
+@requires_gdal2(reason="TIFF block size access requires GDAL 2.0")
 def test_block_size_tiff(path_rgb_byte_tif):
     """Without compression a TIFF's blocks are all the same size"""
     with rasterio.open(path_rgb_byte_tif) as src:
@@ -181,9 +178,7 @@ def test_block_size_tiff(path_rgb_byte_tif):
         assert sizes.count(7119) == len(block_windows) - 1
 
 
-@pytest.mark.skipif(
-    parse(rasterio.__gdal_version__) < parse('2.0.0'),
-    reason="TIFF block size access requires GDAL 2.0")
+@requires_gdal2(reason="TIFF block size access requires GDAL 2.0")
 def test_block_size_exception():
     """A JPEG has no TIFF metadata and no API for block size"""
     with pytest.raises(RasterBlockError):
diff --git a/tests/test_colorinterp.py b/tests/test_colorinterp.py
--- a/tests/test_colorinterp.py
+++ b/tests/test_colorinterp.py
@@ -1,12 +1,12 @@
 """Tests for interacting with color interpretation."""
 
-
-from packaging.version import Version
 import pytest
 
 import rasterio
 from rasterio.enums import ColorInterp
 
+from .conftest import requires_gdal22
+
 
 def test_cmyk_interp(tmpdir):
     """A CMYK TIFF has cyan, magenta, yellow, black bands."""
@@ -24,9 +24,8 @@ def test_cmyk_interp(tmpdir):
             ColorInterp.black)
 
 
-@pytest.mark.skipif(
-    Version(rasterio.__gdal_version__) < Version('2.2.2'),
-    reason="Some prior versions segfault on a Mac OSX Homebrew GDAL install.")
+@requires_gdal22(reason="Some versions prior to 2.2.2 segfault on a Mac OSX "
+                        "Homebrew GDAL")
 def test_ycbcr_interp(tmpdir):
     """A YCbCr TIFF has red, green, blue bands."""
     with rasterio.open('tests/data/RGB.byte.tif') as src:
diff --git a/tests/test_crs.py b/tests/test_crs.py
--- a/tests/test_crs.py
+++ b/tests/test_crs.py
@@ -1,16 +1,15 @@
 import logging
 import subprocess
-import sys
 import json
 
-from packaging.version import parse
 import pytest
 
 import rasterio
 from rasterio._base import _can_create_osr
 from rasterio.crs import CRS
 from rasterio.errors import CRSError
-from rasterio.io import DatasetWriter
+
+from .conftest import requires_gdal21
 
 
 @pytest.fixture(scope='session')
@@ -243,7 +242,7 @@ def test_safe_osr_release(tmpdir):
     assert "Pointer 'hSRS' is NULL in 'OSRRelease'" not in log
 
 
-@pytest.mark.xfail(parse(rasterio.__gdal_version__) < parse('2.1'), reason="CRS equality is buggy pre-2.1")
+@requires_gdal21(reason="CRS equality is buggy pre-2.1")
 def test_from_wkt():
     wgs84 = CRS.from_string('+proj=longlat +datum=WGS84 +no_defs')
     from_wkt = CRS.from_wkt(wgs84.wkt)
diff --git a/tests/test_env.py b/tests/test_env.py
--- a/tests/test_env.py
+++ b/tests/test_env.py
@@ -5,23 +5,19 @@
 import sys
 
 import boto3
-from packaging.version import parse
 import pytest
 
 import rasterio
 from rasterio._env import del_gdal_config, get_gdal_config, set_gdal_config
-from rasterio._err import CPLE_BaseError
 from rasterio.env import defenv, delenv, getenv, setenv, ensure_env
-from rasterio.env import default_options
+from rasterio.env import default_options, GDALVersion
 from rasterio.errors import EnvError, RasterioIOError
 from rasterio.rio.main import main_group
 
+from .conftest import requires_gdal21
 
-# Custom markers.
-mingdalversion = pytest.mark.skipif(
-    parse(rasterio.__gdal_version__) < parse('2.1.0dev'),
-    reason="S3 raster access requires GDAL 2.1")
 
+# Custom markers.
 credentials = pytest.mark.skipif(
     not(boto3.Session()._session.get_credentials()),
     reason="S3 raster access requires credentials")
@@ -210,7 +206,7 @@ def test_skip_gtiff(gdalenv):
             rasterio.open('tests/data/RGB.byte.tif')
 
 
-@mingdalversion
+@requires_gdal21(reason="S3 access requires 2.1+")
 @credentials
 @pytest.mark.network
 def test_s3_open_with_env(gdalenv):
@@ -220,7 +216,7 @@ def test_s3_open_with_env(gdalenv):
             assert dataset.count == 1
 
 
-@mingdalversion
+@requires_gdal21(reason="S3 access requires 2.1+")
 @credentials
 @pytest.mark.network
 def test_s3_open_with_implicit_env(gdalenv):
@@ -229,7 +225,7 @@ def test_s3_open_with_implicit_env(gdalenv):
         assert dataset.count == 1
 
 
-@mingdalversion
+@requires_gdal21(reason="S3 access requires 2.1+")
 @credentials
 @pytest.mark.network
 def test_env_open_s3(gdalenv):
@@ -241,7 +237,7 @@ def test_env_open_s3(gdalenv):
             assert dataset.count == 1
 
 
-@mingdalversion
+@requires_gdal21(reason="S3 access requires 2.1+")
 @credentials
 @pytest.mark.network
 def test_env_open_s3_credentials(gdalenv):
@@ -252,7 +248,7 @@ def test_env_open_s3_credentials(gdalenv):
             assert dataset.count == 1
 
 
-@mingdalversion
+@requires_gdal21(reason="S3 access requires 2.1+")
 @credentials
 @pytest.mark.network
 def test_ensured_env_no_credentializing(gdalenv):
@@ -263,7 +259,7 @@ def test_ensured_env_no_credentializing(gdalenv):
             rasterio.open(L8TIFB2)
 
 
-@mingdalversion
+@requires_gdal21(reason="S3 access requires 2.1+")
 @pytest.mark.network
 def test_open_https_vsicurl(gdalenv):
     """Read from HTTPS URL."""
@@ -273,7 +269,7 @@ def test_open_https_vsicurl(gdalenv):
 
 # CLI tests.
 
-@mingdalversion
+@requires_gdal21(reason="S3 access requires 2.1+")
 @credentials
 @pytest.mark.network
 def test_s3_rio_info(runner):
@@ -283,7 +279,7 @@ def test_s3_rio_info(runner):
     assert '"crs": "EPSG:32645"' in result.output
 
 
-@mingdalversion
+@requires_gdal21(reason="S3 access requires 2.1+")
 @credentials
 @pytest.mark.network
 def test_https_rio_info(runner):
@@ -399,3 +395,62 @@ def test_gdal_cachemax():
         set_gdal_config('GDAL_CACHEMAX', original_cachemax)
     except OverflowError:
         set_gdal_config('GDAL_CACHEMAX', int(original_cachemax / 1000000))
+
+
+def test_gdalversion_class_from_string():
+    v = GDALVersion.from_string('1.9.0')
+    assert v.major == 1 and v.minor == 9
+
+    v = GDALVersion.from_string('1.9')
+    assert v.major == 1 and v.minor == 9
+
+    v = GDALVersion.from_string('1.9a')
+    assert v.major == 1 and v.minor == 9
+
+
+def test_gdalversion_class_from_string_err():
+    invalids = ('foo', 'foo.bar', '1', '1.', '1.a', '.1')
+
+    for invalid in invalids:
+        with pytest.raises(ValueError):
+            GDALVersion.from_string(invalid)
+
+
+def test_gdalversion_class_runtime():
+    """Test the version of GDAL from this runtime"""
+    GDALVersion.runtime().major >= 1
+
+
+def test_gdalversion_class_cmp():
+    assert GDALVersion(1, 0) == GDALVersion(1, 0)
+    assert GDALVersion(2, 0) > GDALVersion(1, 0)
+    assert GDALVersion(1, 1) > GDALVersion(1, 0)
+    assert GDALVersion(1, 2) < GDALVersion(2, 2)
+
+    # Because we don't care about patch component
+    assert GDALVersion.from_string('1.0') == GDALVersion.from_string('1.0.10')
+
+    assert GDALVersion.from_string('1.9') < GDALVersion.from_string('2.2.0')
+    assert GDALVersion.from_string('2.0.0') > GDALVersion(1, 9)
+
+
+def test_gdalversion_class_repr():
+    assert str(GDALVersion(2, 1)) == 'GDALVersion(major=2, minor=1)'
+
+
+def test_gdalversion_class_at_least():
+    assert GDALVersion(2, 1).at_least(GDALVersion(1, 9))
+    assert GDALVersion(2, 1).at_least((1, 9))
+    assert GDALVersion(2, 1).at_least('1.9')
+
+    assert not GDALVersion(2, 1).at_least(GDALVersion(2, 2))
+    assert not GDALVersion(2, 1).at_least((2, 2))
+    assert not GDALVersion(2, 1).at_least('2.2')
+
+
+def test_gdalversion_class_at_least_invalid_type():
+    invalids_types = ({}, {'major': 1, 'minor': 1}, [1, 2])
+
+    for invalid in invalids_types:
+        with pytest.raises(TypeError):
+            GDALVersion(2, 1).at_least(invalid)
\ No newline at end of file
diff --git a/tests/test_memoryfile.py b/tests/test_memoryfile.py
--- a/tests/test_memoryfile.py
+++ b/tests/test_memoryfile.py
@@ -1,22 +1,22 @@
-"""MemoryFile tests"""
+"""MemoryFile tests.  MemoryFile requires GDAL 2.0+.
+Tests in this file will ONLY run for GDAL >= 2.x"""
 
 from io import BytesIO
 import logging
-
-from packaging.version import parse
 import pytest
 
 import rasterio
 from rasterio.io import MemoryFile, ZipMemoryFile
-
+from rasterio.env import GDALVersion
 
 logging.basicConfig(level=logging.DEBUG)
 
 
-# Custom markers.
-mingdalversion = pytest.mark.skipif(
-    parse(rasterio.__gdal_version__) < parse('2.0dev'),
-    reason="MemoryFile requires GDAL 2.0")
+# Skip ENTIRE module if not GDAL >= 2.x.
+# pytestmark is a keyword that instructs pytest to skip this module.
+pytestmark = pytest.mark.skipif(
+    not GDALVersion.runtime().major >= 2,
+    reason="MemoryFile requires GDAL 2.x")
 
 
 @pytest.fixture(scope='session')
@@ -45,14 +45,12 @@ def rgb_data_and_profile(path_rgb_byte_tif):
     return data, profile
 
 
-@mingdalversion
 def test_initial_not_bytes():
     """Creating a MemoryFile from not bytes fails."""
     with pytest.raises(TypeError):
         MemoryFile(u'lolwut')
 
 
-@mingdalversion
 def test_initial_bytes(rgb_file_bytes):
     """MemoryFile contents can initialized from bytes and opened."""
     with MemoryFile(rgb_file_bytes) as memfile:
@@ -63,7 +61,6 @@ def test_initial_bytes(rgb_file_bytes):
             assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 def test_initial_lzw_bytes(rgb_lzw_file_bytes):
     """MemoryFile contents can initialized from bytes and opened."""
     with MemoryFile(rgb_lzw_file_bytes) as memfile:
@@ -74,7 +71,6 @@ def test_initial_lzw_bytes(rgb_lzw_file_bytes):
             assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 def test_initial_file_object(rgb_file_object):
     """MemoryFile contents can initialized from bytes and opened."""
     with MemoryFile(rgb_file_object) as memfile:
@@ -85,7 +81,6 @@ def test_initial_file_object(rgb_file_object):
             assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 def test_closed():
     """A closed MemoryFile can not be opened"""
     with MemoryFile() as memfile:
@@ -94,7 +89,6 @@ def test_closed():
         memfile.open()
 
 
-@mingdalversion
 def test_non_initial_bytes(rgb_file_bytes):
     """MemoryFile contents can be read from bytes and opened."""
     with MemoryFile() as memfile:
@@ -106,7 +100,6 @@ def test_non_initial_bytes(rgb_file_bytes):
             assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 def test_non_initial_bytes_in_two(rgb_file_bytes):
     """MemoryFile contents can be read from bytes in two steps and opened."""
     with MemoryFile() as memfile:
@@ -119,7 +112,6 @@ def test_non_initial_bytes_in_two(rgb_file_bytes):
             assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 def test_non_initial_bytearray(rgb_file_bytes):
     """MemoryFile contents can be read from bytearray and opened."""
     with MemoryFile() as memfile:
@@ -131,7 +123,6 @@ def test_non_initial_bytearray(rgb_file_bytes):
             assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 def test_no_initial_bytes(rgb_data_and_profile):
     """An empty MemoryFile can be opened and written into."""
     data, profile = rgb_data_and_profile
@@ -150,7 +141,6 @@ def test_no_initial_bytes(rgb_data_and_profile):
             assert sorted(src.profile.items()) == sorted(profile.items())
 
 
-@mingdalversion
 def test_read(tmpdir, rgb_file_bytes):
     """Reading from a MemoryFile works"""
     with MemoryFile(rgb_file_bytes) as memfile:
@@ -166,7 +156,6 @@ def test_read(tmpdir, rgb_file_bytes):
         assert src.count == 3
 
 
-@mingdalversion
 def test_file_object_read(rgb_file_object):
     """An example of reading from a file object"""
     with rasterio.open(rgb_file_object) as src:
@@ -176,7 +165,6 @@ def test_file_object_read(rgb_file_object):
         assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 def test_file_object_read_variant(rgb_file_bytes):
     """An example of reading from a MemoryFile object"""
     with rasterio.open(MemoryFile(rgb_file_bytes)) as src:
@@ -186,7 +174,6 @@ def test_file_object_read_variant(rgb_file_bytes):
         assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 def test_file_object_read_variant2(rgb_file_bytes):
     """An example of reading from a BytesIO object"""
     with rasterio.open(BytesIO(rgb_file_bytes)) as src:
@@ -196,7 +183,6 @@ def test_file_object_read_variant2(rgb_file_bytes):
         assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 def test_test_file_object_write(tmpdir, rgb_data_and_profile):
     """An example of writing to a file object"""
     data, profile = rgb_data_and_profile
@@ -211,7 +197,6 @@ def test_test_file_object_write(tmpdir, rgb_data_and_profile):
         assert src.read().shape == (3, 718, 791)
 
 
-@mingdalversion
 @pytest.mark.xfail(reason="in-memory files created within open() "
                           "are not persistent")
 def test_nonpersistemt_memfile_fail_example(rgb_data_and_profile):
@@ -226,7 +211,6 @@ def test_nonpersistemt_memfile_fail_example(rgb_data_and_profile):
         rasterio.open(fout)
 
 
-@mingdalversion
 def test_zip_closed():
     """A closed ZipMemoryFile can not be opened"""
     with ZipMemoryFile() as zipmemfile:
@@ -235,7 +219,6 @@ def test_zip_closed():
         zipmemfile.open('foo')
 
 
-@mingdalversion
 def test_zip_file_object_read(path_zip_file):
     """An example of reading from a zip file object"""
     with open(path_zip_file, 'rb') as zip_file_object:
diff --git a/tests/test_open_options.py b/tests/test_open_options.py
--- a/tests/test_open_options.py
+++ b/tests/test_open_options.py
@@ -1,46 +1,42 @@
 """Tests of dataset opening options and driver choice"""
 
-from packaging.version import parse
 import pytest
 
 import rasterio
 from rasterio.transform import Affine
 
+from .conftest import requires_only_gdal1, requires_gdal2, requires_gdal22
 
-@pytest.mark.skipif(parse(rasterio.__gdal_version__) >= parse('2.0'),
-                    reason="Only relevant for GDAL 1.x")
+
+@requires_only_gdal1
 def test_driver_option_not_implemented():
     """Driver selection is not supported by GDAL 1.x"""
     with pytest.raises(rasterio.errors.GDALOptionNotImplementedError):
         rasterio.open('tests/data/RGB.byte.tif', driver='BMP')
 
 
-@pytest.mark.skipif(parse(rasterio.__gdal_version__) >= parse('2.0'),
-                    reason="Only relevant for GDAL 1.x")
+@requires_only_gdal1
 def test_open_options_not_implemented():
     """Open options are not supported by GDAL 1.x"""
     with pytest.raises(rasterio.errors.GDALOptionNotImplementedError):
         rasterio.open('tests/data/RGB.byte.tif', NUM_THREADS=42)
 
 
-@pytest.mark.skipif(parse(rasterio.__gdal_version__) < parse('2.0'),
-                    reason="Requires a GDAL 2.0 feature")
+@requires_gdal2
 def test_fail_with_missing_driver():
     """Fail to open a GeoTIFF without the GTiff driver"""
     with pytest.raises(rasterio.errors.RasterioIOError):
         rasterio.open('tests/data/RGB.byte.tif', driver='BMP')
 
 
-@pytest.mark.skipif(parse(rasterio.__gdal_version__) < parse('2.0'),
-                    reason="Requires a GDAL 2.0 feature")
+@requires_gdal2
 def test_open_specific_driver():
     """Open a GeoTIFF with the GTiff driver"""
     with rasterio.open('tests/data/RGB.byte.tif', driver='GTiff') as src:
         assert src.count == 3
 
 
-@pytest.mark.skipif(parse(rasterio.__gdal_version__) < parse('2.2'),
-                    reason="Requires a GDAL 2.2 feature")
+@requires_gdal22
 def test_open_specific_driver_with_options():
     """Open a GeoTIFF with the GTiff driver and GEOREF_SOURCES option"""
     with rasterio.open(
diff --git a/tests/test_read_resample.py b/tests/test_read_resample.py
--- a/tests/test_read_resample.py
+++ b/tests/test_read_resample.py
@@ -1,12 +1,12 @@
 import numpy as np
 
-from packaging.version import parse
-import pytest
-
 import rasterio
 from rasterio.enums import Resampling
 from rasterio.windows import Window
 
+from .conftest import requires_gdal2
+
+
 # Rasterio exposes GDAL's resampling/decimation on I/O. These are the tests
 # that it does this correctly.
 #
@@ -50,9 +50,7 @@ def test_read_downsample_alpha():
             src.read(4, out=out, masked=False)
 
 
-@pytest.mark.skipif(
-    parse(rasterio.__gdal_version__) < parse('2.0dev'),
-    reason="Extra resampling algorithms require GDAL>2.0")
+@requires_gdal2
 def test_resample_alg():
     """default (nearest) and cubic produce different results"""
     with rasterio.open('tests/data/RGB.byte.tif') as s:
@@ -62,9 +60,7 @@ def test_resample_alg():
         assert np.any(nearest != cubic)
 
 
-@pytest.mark.skipif(
-    parse(rasterio.__gdal_version__) < parse('2.0dev'),
-    reason="Floating point windows require GDAL>2.0")
+@requires_gdal2
 def test_float_window():
     """floating point windows work"""
     with rasterio.open('tests/data/RGB.byte.tif') as s:
diff --git a/tests/test_rio_edit_info.py b/tests/test_rio_edit_info.py
--- a/tests/test_rio_edit_info.py
+++ b/tests/test_rio_edit_info.py
@@ -5,17 +5,19 @@
 
 import click
 from click.testing import CliRunner
-from packaging.version import Version, parse
 import pytest
 
 import rasterio
 from rasterio.enums import ColorInterp
+from rasterio.env import GDALVersion
 from rasterio.rio.edit_info import (
     all_handler, crs_handler, tags_handler, transform_handler,
     colorinterp_handler)
 from rasterio.rio.main import main_group
 import rasterio.shutil
 
+from .conftest import requires_gdal110, requires_less_than_gdal110, requires_gdal21
+
 
 PARAM_HANDLER = {
     'crs': crs_handler,
@@ -55,9 +57,7 @@ def test_delete_crs_exclusive_opts(data):
     assert result.exit_code == 2
 
 
-@pytest.mark.skip(
-    parse(rasterio.__gdal_version__) < parse('1.10'),
-    reason='GDAL version >= 1.10 required')
+@requires_gdal110
 def test_unset_crs(data):
     runner = CliRunner()
     inputfile = str(data.join('RGB.byte.tif'))
@@ -67,10 +67,7 @@ def test_unset_crs(data):
     with rasterio.open(inputfile) as src:
         assert src.crs is None
 
-
-@pytest.mark.skip(
-    parse(rasterio.__gdal_version__) >= parse('1.10'),
-    reason='Test applies to GDAL version < 1.10')
+@requires_less_than_gdal110
 def test_unset_crs_gdal19(data):
     """unsetting crs doesn't work for geotiff and gdal 1.9
     and should emit an warning"""
@@ -94,9 +91,7 @@ def test_edit_nodata_err(data):
     assert result.exit_code == 2
 
 
-@pytest.mark.skipif(
-    Version(rasterio.__gdal_version__) < Version('2.1'),
-    reason='GDAL version >= 2.1 required')
+@requires_gdal21
 def test_delete_nodata(data):
     """Delete a dataset's nodata value"""
     runner = CliRunner()
@@ -429,9 +424,7 @@ def test_like_band_count_mismatch(runner, data):
     assert "When using '--like' for color interpretation" in result.output
 
 
-@pytest.mark.skipif(
-    Version(rasterio.__gdal_version__) < Version('2.1'),
-    reason='GDAL version >= 2.1 required')
+@requires_gdal21
 def test_colorinterp_like_all(
         runner, path_4band_no_colorinterp, path_rgba_byte_tif, tmpdir):
     """Test setting colorinterp via '--like template --all'."""
diff --git a/tests/test_rio_info.py b/tests/test_rio_info.py
--- a/tests/test_rio_info.py
+++ b/tests/test_rio_info.py
@@ -1,12 +1,14 @@
 import json
 
 from click.testing import CliRunner
-from packaging.version import Version, parse
 import pytest
 
 import numpy as np
 import rasterio
 from rasterio.rio.main import main_group
+from rasterio.env import GDALVersion
+
+from .conftest import requires_gdal110, requires_less_than_gdal110, requires_gdal21
 
 
 with rasterio.Env() as env:
@@ -31,9 +33,7 @@ def test_delete_crs_exclusive_opts(data):
     assert result.exit_code == 2
 
 
-@pytest.mark.skip(
-    parse(rasterio.__gdal_version__) < parse('1.10'),
-    reason='GDAL version >= 1.10 required')
+@requires_gdal110
 def test_unset_crs(data):
     runner = CliRunner()
     inputfile = str(data.join('RGB.byte.tif'))
@@ -44,9 +44,7 @@ def test_unset_crs(data):
         assert src.crs is None
 
 
-@pytest.mark.skip(
-    parse(rasterio.__gdal_version__) >= parse('1.10'),
-    reason='Test applies to GDAL version < 1.10')
+@requires_less_than_gdal110
 def test_unset_crs_gdal19(data):
     """unsetting crs doesn't work for geotiff and gdal 1.9
     and should emit an warning"""
@@ -70,9 +68,7 @@ def test_edit_nodata_err(data):
     assert result.exit_code == 2
 
 
-@pytest.mark.xfail(
-    Version(rasterio.__gdal_version__) < Version('2.1'),
-    reason='GDAL version >= 2.1 required')
+@requires_gdal21
 def test_delete_nodata(data):
     """Delete a dataset's nodata value"""
     runner = CliRunner()
@@ -706,8 +702,7 @@ def test_info_checksums_only():
     assert result.output.strip() == '29131'
 
 
-@pytest.mark.skipif(parse(rasterio.__gdal_version__) < parse('2.1'),
-                    reason='netCDF requires GDAL 2.1+')
+@requires_gdal21(reason="NetCDF requires GDAL 2.1+")
 @pytest.mark.skipif(not HAVE_NETCDF,
                     reason="GDAL not compiled with NetCDF driver.")
 def test_info_subdatasets():
diff --git a/tests/test_rio_mask.py b/tests/test_rio_mask.py
--- a/tests/test_rio_mask.py
+++ b/tests/test_rio_mask.py
@@ -6,17 +6,17 @@
 
 import affine
 import numpy as np
-from packaging.version import parse
 import pytest
 
 import rasterio
 from rasterio.crs import CRS
 from rasterio.rio.main import main_group
+from rasterio.env import GDALVersion
 
 
 # Custom markers.
 xfail_pixel_sensitive_gdal22 = pytest.mark.xfail(
-    parse(rasterio.__gdal_version__) < parse('2.2'),
+    not GDALVersion.runtime().at_least('2.2'),
     reason="This test is sensitive to pixel values and requires GDAL 2.2+")
 
 
diff --git a/tests/test_rio_merge.py b/tests/test_rio_merge.py
--- a/tests/test_rio_merge.py
+++ b/tests/test_rio_merge.py
@@ -8,7 +8,6 @@
 import affine
 from click.testing import CliRunner
 import numpy as np
-from packaging.version import parse
 from pytest import fixture
 import pytest
 
@@ -17,11 +16,13 @@
 from rasterio.rio.main import main_group
 from rasterio.transform import Affine
 
+from rasterio.env import GDALVersion
+
 
 # Custom markers.
 xfail_pixel_sensitive_gdal2 = pytest.mark.xfail(
-    parse(rasterio.__gdal_version__) < parse('2.0dev'),
-    reason="This test is sensitive to pixel values and requires GDAL 2.0+")
+    not GDALVersion.runtime().at_least('2.0'),
+    reason="This test is sensitive to pixel values and requires GDAL 2.2+")
 
 
 # Fixture to create test datasets within temporary directory
diff --git a/tests/test_subdatasets.py b/tests/test_subdatasets.py
--- a/tests/test_subdatasets.py
+++ b/tests/test_subdatasets.py
@@ -1,15 +1,14 @@
-from packaging.version import parse
 import pytest
 
 import rasterio
 
+from .conftest import requires_gdal21
 
 with rasterio.Env() as env:
     HAVE_NETCDF = 'NetCDF' in env.drivers().keys()
 
 
-@pytest.mark.skipif(parse(rasterio.__gdal_version__) < parse('2.1'),
-                    reason="netcdf driver not available before GDAL 2.1")
+@requires_gdal21(reason="NetCDF requires GDAL 2.1+")
 @pytest.mark.skipif(not HAVE_NETCDF,
                     reason="GDAL not compiled with NetCDF driver.")
 def test_subdatasets():
diff --git a/tests/test_update.py b/tests/test_update.py
--- a/tests/test_update.py
+++ b/tests/test_update.py
@@ -1,13 +1,15 @@
-import shutil
 import subprocess
 import re
 
 import affine
 import numpy as np
-from packaging.version import Version
 import pytest
 
 import rasterio
+from rasterio.env import GDALVersion
+
+from .conftest import requires_gdal21
+
 
 def test_update_tags(data):
     tiffname = str(data.join('RGB.byte.tif'))
@@ -65,7 +67,7 @@ def test_update_nodatavals(data):
 
 
 @pytest.mark.skipif(
-    Version(rasterio.__gdal_version__) >= Version('2.1'),
+    GDALVersion.runtime().at_least('2.1'),
     reason='Tests behavior specific to GDAL versions < 2.1')
 def test_update_nodatavals_none_fails(data):
     """GDAL 2.0 doesn't support un-setting nodata values."""
@@ -75,9 +77,7 @@ def test_update_nodatavals_none_fails(data):
             f.nodata = None
 
 
-@pytest.mark.skipif(
-    Version(rasterio.__gdal_version__) < Version('2.1'),
-    reason='Tests behavior specific to GDAL versions >= 2.1')
+@requires_gdal21
 def test_update_nodatavals_none(data):
     """GDAL 2.1 does support un-setting nodata values."""
     tiffname = str(data.join('RGB.byte.tif'))
diff --git a/tests/test_warp.py b/tests/test_warp.py
--- a/tests/test_warp.py
+++ b/tests/test_warp.py
@@ -5,19 +5,18 @@
 import pytest
 from affine import Affine
 import numpy as np
-from packaging.version import parse
 
 import rasterio
 from rasterio.control import GroundControlPoint
-from rasterio.crs import CRS
 from rasterio.enums import Resampling
+from rasterio.env import GDALVersion
 from rasterio.errors import GDALBehaviorChangeException, CRSError
 from rasterio.warp import (
     reproject, transform_geom, transform, transform_bounds,
     calculate_default_transform)
 from rasterio import windows
 
-
+gdal_version = GDALVersion.runtime()
 logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
 
 
@@ -43,10 +42,9 @@ def supported_resampling(method):
     gdal2plus_only = (
         Resampling.max, Resampling.min, Resampling.med,
         Resampling.q1, Resampling.q3)
-    version = parse(rasterio.__gdal_version__)
-    if version < parse('1.10'):
+    if not gdal_version.at_least('1.10'):
         return method not in gdal2plus_only and method not in gdal110plus_only
-    if version < parse('2.0'):
+    if not gdal_version.at_least('2.0'):
         return method not in gdal2plus_only
     return True
 
@@ -1050,7 +1048,7 @@ def test_reproject_gcps(rgb_byte_profile):
 
 
 @pytest.mark.skipif(
-    parse(rasterio.__gdal_version__) < parse('2.2.0'),
+    not gdal_version.at_least('2.2'),
     reason="GDAL 2.2.0 and newer has different antimeridian cutting behavior.")
 def test_transform_geom_gdal22():
     """Enabling `antimeridian_cutting` has no effect on GDAL 2.2.0 or newer
diff --git a/tests/test_warpedvrt.py b/tests/test_warpedvrt.py
--- a/tests/test_warpedvrt.py
+++ b/tests/test_warpedvrt.py
@@ -1,19 +1,15 @@
 import boto3
-from packaging.version import parse
 import pytest
 
 import rasterio
 from rasterio.enums import Resampling
 from rasterio.transform import Affine
 from rasterio.vrt import WarpedVRT
-from rasterio.windows import Window
 
+from .conftest import requires_gdal21
 
-# Custom markers.
-mingdalversion = pytest.mark.skipif(
-    parse(rasterio.__gdal_version__) < parse('2.1.0dev'),
-    reason="S3 raster access requires GDAL 2.1")
 
+# Custom markers.
 credentials = pytest.mark.skipif(
     not(boto3.Session()._session.get_credentials()),
     reason="S3 raster access requires credentials")
@@ -85,7 +81,7 @@ def test_warp_extras(path_rgb_byte_tif):
             assert (rgb[:, 0, 0] == 255).all()
 
 
-@mingdalversion
+@requires_gdal21(reason="S3 raster access requires GDAL 2.1+")
 @credentials
 @pytest.mark.network
 def test_wrap_s3():