Repository: rasterio/rasterio

Issue:
rio warp null transformer error with bad proj4
Currently, if you pass a bad projection, you get the following behavior:

```
$ rio warp --dst-crs "+proj=foobar" tests/data/warp_test.tif /tmp/foo.tif
ERROR:GDAL:CPLE_NotSupported in Failed to initialize PROJ.4 with `+proj=foobar +wktext'.
Traceback (most recent call last):
...
  File "/Users/mperry/work/rasterio/rasterio/rio/warp.py", line 198, in warp
    resolution=res)
  File "/Users/mperry/work/rasterio/rasterio/warp.py", line 296, in calculate_default_transform
    left, bottom, right, top)
  File "rasterio/_warp.pyx", line 535, in rasterio._warp._calculate_default_transform (rasterio/_warp.cpp:9551)
    with InMemoryRaster(
  File "rasterio/_warp.pyx", line 542, in rasterio._warp._calculate_default_transform (rasterio/_warp.cpp:9261)
    raise ValueError("NULL transformer")
ValueError: NULL transformer
```

The transformer fails to initialize, which is reasonable considering the invalid proj string. Is there any way to catch that error and report back something more meaningful than "NULL transformer"?



Discussion:
@perrygeo I can't reproduce this in tests (see #598) so I'm inclined to close or push it to 0.33. Okay?

@sgillies Those tests in #598 pass for me as well. Still seeing the command line behavior but it's not a blocker :football: 

I'm gonna re-open for 0.33, I still see this on the command line, just not in direct calls to `calculate_default_transform`

So those tests in #598 show that a bad _string_ won't raise an exception

```
        dt, dw, dh = _calculate_default_transform(
            {'init': 'EPSG:4326'},
            '+proj=foobar',
```

But a bad _crs dict_ which will trigger the `ValueError`

```
        dt, dw, dh = _calculate_default_transform(
            {'init': 'EPSG:4326'},
            {'proj': 'foobar'},
```

I'd say that to resolve this:
1. `calculate_default_transform` should validate both crs inputs ala `dst_crs = rasterio.crs.from_string(dst_crs)`
2. Catch the invalid projection somewhere in `_calculate_default_transform`, either at the osr step or after the transformer fails (this part is less clear to me) 


Resolving patch:
diff --git a/rasterio/errors.py b/rasterio/errors.py
--- a/rasterio/errors.py
+++ b/rasterio/errors.py
@@ -3,16 +3,22 @@
 from click import FileError
 
 
-class RasterioIOError(IOError):
-    """A failure to open a dataset using the presently registered drivers."""
+class CRSError(ValueError):
+    """Raised when a CRS string or mapping is invalid or cannot serve
+    to define a coordinate transformation."""
 
 
 class DriverRegistrationError(ValueError):
-    """To be raised when, eg, _gdal.GDALGetDriverByName("MEM") returns NULL."""
+    """Raised when a format driver is requested but is not registered."""
 
 
 class FileOverwriteError(FileError):
-    """Rasterio's CLI refuses to implicitly clobber output files."""
+    """Raised when Rasterio's CLI refuses to clobber output files."""
 
     def __init__(self, message):
         super(FileOverwriteError, self).__init__('', hint=message)
+
+
+class RasterioIOError(IOError):
+    """Raised when a dataset cannot be opened using one of the
+    registered format drivers."""
diff --git a/rasterio/rio/warp.py b/rasterio/rio/warp.py
--- a/rasterio/rio/warp.py
+++ b/rasterio/rio/warp.py
@@ -9,6 +9,7 @@
 from . import options
 import rasterio
 from rasterio import crs
+from rasterio.errors import CRSError
 from rasterio.transform import Affine
 from rasterio.warp import (reproject, Resampling, calculate_default_transform,
    transform_bounds)
@@ -159,16 +160,20 @@ def warp(ctx, files, output, driver, like, dst_crs, dimensions, src_bounds,
             elif dst_crs:
                 try:
                     dst_crs = crs.from_string(dst_crs)
-                except ValueError:
-                    raise click.BadParameter("invalid crs format.",
-                                             param=dst_crs, param_hint=dst_crs)
+                except ValueError as err:
+                    raise click.BadParameter(
+                        str(err), param='dst_crs', param_hint='dst_crs')
 
                 if dimensions:
                     # Calculate resolution appropriate for dimensions
                     # in target.
                     dst_width, dst_height = dimensions
-                    xmin, ymin, xmax, ymax = transform_bounds(src.crs, dst_crs,
-                                                              *src.bounds)
+                    try:
+                        xmin, ymin, xmax, ymax = transform_bounds(
+                            src.crs, dst_crs, *src.bounds)
+                    except CRSError as err:
+                        raise click.BadParameter(
+                            str(err), param='dst_crs', param_hint='dst_crs')
                     dst_transform = Affine(
                         (xmax - xmin) / float(dst_width),
                         0, xmin, 0,
@@ -183,8 +188,13 @@ def warp(ctx, files, output, driver, like, dst_crs, dimensions, src_bounds,
                             param='res', param_hint='res')
 
                     if src_bounds:
-                        xmin, ymin, xmax, ymax = transform_bounds(
-                            src.crs, dst_crs, *src_bounds)
+                        try:
+                            xmin, ymin, xmax, ymax = transform_bounds(
+                                src.crs, dst_crs, *src_bounds)
+                        except CRSError as err:
+                            raise click.BadParameter(
+                                str(err), param='dst_crs',
+                                param_hint='dst_crs')
                     else:
                         xmin, ymin, xmax, ymax = dst_bounds
 
@@ -193,10 +203,13 @@ def warp(ctx, files, output, driver, like, dst_crs, dimensions, src_bounds,
                     dst_height = max(int(ceil((ymax - ymin) / res[1])), 1)
 
                 else:
-                    dst_transform, dst_width, dst_height = calculate_default_transform(
-                        src.crs, dst_crs, src.width, src.height, *src.bounds,
-                        resolution=res)
-
+                    try:
+                        dst_transform, dst_width, dst_height = calculate_default_transform(
+                            src.crs, dst_crs, src.width, src.height,
+                            *src.bounds, resolution=res)
+                    except CRSError as err:
+                        raise click.BadParameter(
+                            str(err), param='dst_crs', param_hint='dst_crs')
             elif dimensions:
                 # Same projection, different dimensions, calculate resolution.
                 dst_crs = src.crs


Test patch:
diff --git a/tests/test_rio_warp.py b/tests/test_rio_warp.py
--- a/tests/test_rio_warp.py
+++ b/tests/test_rio_warp.py
@@ -8,11 +8,55 @@
 
 import rasterio
 from rasterio.rio import warp
+from rasterio.rio.main import main_group
 
 
 logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
 
 
+def test_dst_crs_error(runner, tmpdir):
+    """Invalid JSON is a bad parameter."""
+    srcname = 'tests/data/RGB.byte.tif'
+    outputname = str(tmpdir.join('test.tif'))
+    result = runner.invoke(main_group, [
+        'warp', srcname, outputname, '--dst-crs', '{foo: bar}'])
+    assert result.exit_code == 2
+    assert 'for dst_crs: crs appears to be JSON but is not' in result.output
+
+
+@pytest.mark.xfail(
+    os.environ.get('GDALVERSION', 'a.b.c').startswith('1.9'),
+                   reason="GDAL 1.9 doesn't catch this error")
+def test_dst_crs_error_2(runner, tmpdir):
+    """Invalid PROJ.4 is a bad parameter."""
+    srcname = 'tests/data/RGB.byte.tif'
+    outputname = str(tmpdir.join('test.tif'))
+    result = runner.invoke(main_group, [
+        'warp', srcname, outputname, '--dst-crs', '{"proj": "foobar"}'])
+    assert result.exit_code == 2
+    assert 'for dst_crs: Failed to initialize PROJ.4' in result.output
+
+
+def test_dst_crs_error_epsg(runner, tmpdir):
+    """Malformed EPSG string is a bad parameter."""
+    srcname = 'tests/data/RGB.byte.tif'
+    outputname = str(tmpdir.join('test.tif'))
+    result = runner.invoke(main_group, [
+        'warp', srcname, outputname, '--dst-crs', 'EPSG:'])
+    assert result.exit_code == 2
+    assert 'for dst_crs: invalid literal for int()' in result.output
+
+
+def test_dst_crs_error_epsg_2(runner, tmpdir):
+    """Invalid EPSG code is a bad parameter."""
+    srcname = 'tests/data/RGB.byte.tif'
+    outputname = str(tmpdir.join('test.tif'))
+    result = runner.invoke(main_group, [
+        'warp', srcname, outputname, '--dst-crs', 'EPSG:0'])
+    assert result.exit_code == 2
+    assert 'for dst_crs: EPSG codes are positive integers' in result.output
+
+
 def test_warp_no_reproject(runner, tmpdir):
     """ When called without parameters, output should be same as source """
     srcname = 'tests/data/shade.tif'
@@ -121,15 +165,6 @@ def test_warp_reproject_dst_crs(runner, tmpdir):
                                    -76.5759177302349, 25.550873767433984])
 
 
-def test_warp_reproject_dst_crs_error(runner, tmpdir):
-    srcname = 'tests/data/RGB.byte.tif'
-    outputname = str(tmpdir.join('test.tif'))
-    result = runner.invoke(warp.warp, [srcname, outputname,
-                                       '--dst-crs', '{foo: bar}'])
-    assert result.exit_code == 2
-    assert 'invalid crs format' in result.output
-
-
 def test_warp_reproject_dst_crs_proj4(runner, tmpdir):
     proj4 = '+proj=longlat +ellps=WGS84 +datum=WGS84'
     srcname = 'tests/data/shade.tif'
diff --git a/tests/test_warp_transform.py b/tests/test_warp_transform.py
--- a/tests/test_warp_transform.py
+++ b/tests/test_warp_transform.py
@@ -1,6 +1,12 @@
+import os
+
+import pytest
+
 import rasterio
 from rasterio._warp import _calculate_default_transform
+from rasterio.errors import CRSError
 from rasterio.transform import Affine, from_bounds
+from rasterio.warp import transform_bounds
 
 
 def test_identity():
@@ -24,6 +30,18 @@ def test_identity():
         assert round(res, 7) == round(exp, 7)
 
 
+def test_transform_bounds():
+    """CRSError is raised."""
+    with rasterio.drivers():
+        left, bottom, right, top = (
+            -11740727.544603072, 4852834.0517692715, -11584184.510675032,
+            5009377.085697309)
+        src_crs = 'EPSG:3857'
+        dst_crs = {'proj': 'foobar'}
+        with pytest.raises(CRSError):
+            transform_bounds(src_crs, dst_crs, left, bottom, right, top)
+
+
 def test_gdal_transform_notnull():
     with rasterio.drivers():
         dt, dw, dh = _calculate_default_transform(
@@ -61,3 +79,20 @@ def test_gdal_transform_fail_src_crs():
             bottom=30,
             right=-80,
             top=70)
+
+
+@pytest.mark.xfail(
+    os.environ.get('GDALVERSION', 'a.b.c').startswith('1.9'),
+                   reason="GDAL 1.9 doesn't catch this error")
+def test_gdal_transform_fail_src_crs():
+    with rasterio.drivers():
+        with pytest.raises(CRSError):
+            dt, dw, dh = _calculate_default_transform(
+                {'init': 'EPSG:4326'},
+                {'proj': 'foobar'},
+                width=80,
+                height=80,
+                left=-120,
+                bottom=30,
+                right=-80,
+                top=70)