Repository: rasterio/rasterio

Issue:
add dst_crs to rio-bounds
Adds a `--dst_crs` option to `rio-bounds`.

Two questions, both very minor.
- The `dst_crs` cli option is now common between Fiona and Rasterio. How about adding it to cligj?
- `rasterio.warp.transform_bounds` accepts arguments in a different order than `rasterio.warp.transform` and `rasterio.warp.transform_geom`, both accepting `src.crs` and `dst.crs` as the first two arguments. How about doing the same for `transform_bounds`? This would permit:

```
rasterio.warp.transform_bounds(src.crs, dst.crs, *src.bounds)
```

/cc @sgillies @brendan-ward 



Discussion:
@kapadia :+1: on rearranging arguments of `transform_bounds` to better match other transform functions.

with the addition of `--dst-crs` here, `--geographic` seems perhaps unnecessary though we may want to keep it while noting it as deprecated till we hit 1.0?  You could default to `EPSG:4326` if `--dst-crs` is not otherwise specified, since that ends up being the default as from `--geographic` (defaults to true).

Please consider adding a bit to the docstring to indicate precedence of the various CRS options.

Please also consider adding a test for the `--dst-crs` option.

Sure, will add tests - I had gone looking for them in `tests/tests_rio_features.py` ...

As for the new option, Fiona uses `--dst_crs`. That's what's in the PR right now. Do we want to change this to use hyphens, `--dst-crs`?

@kapadia yes, hyphens, per #324

@sgillies @brendan-ward Feel free to merge. I can't explain why coverage went down after a commit to the docstring ...

Thanks, @kapadia :guitar: 


Resolving patch:
diff --git a/rasterio/__init__.py b/rasterio/__init__.py
--- a/rasterio/__init__.py
+++ b/rasterio/__init__.py
@@ -53,7 +53,7 @@ def window_index(*args, **kwargs):
 
 __all__ = [
     'band', 'open', 'copy', 'pad']
-__version__ = "1.0a3"
+__version__ = "1.0a4"
 __gdal_version__ = gdal_version()
 
 # Rasterio attaches NullHandler to the 'rasterio' logger and its
@@ -163,9 +163,7 @@ def open(path, mode='r', driver=None, width=None, height=None,
     be an Apache Commons VFS style string beginning with "zip://" or
     "tar://"". In this case, the ``path`` must be an absolute path
     within that container.
-
     """
-
     if not isinstance(path, string_types):
         raise TypeError("invalid path: {0!r}".format(path))
     if mode and not isinstance(mode, string_types):
@@ -174,7 +172,8 @@ def open(path, mode='r', driver=None, width=None, height=None,
         raise TypeError("invalid driver: {0!r}".format(driver))
     if dtype and not check_dtype(dtype):
         raise TypeError("invalid dtype: {0!r}".format(dtype))
-
+    if nodata is not None:
+        nodata = float(nodata)
     if 'affine' in kwargs:
         # DeprecationWarning's are ignored by default
         with warnings.catch_warnings():
diff --git a/rasterio/rio/clip.py b/rasterio/rio/clip.py
--- a/rasterio/rio/clip.py
+++ b/rasterio/rio/clip.py
@@ -9,6 +9,21 @@
 from rasterio.coords import disjoint_bounds
 
 
+# Geographic (default), projected, or Mercator switch.
+projection_geographic_opt = click.option(
+    '--geographic',
+    'projection',
+    flag_value='geographic',
+    help="Bounds in geographic coordinates.")
+
+projection_projected_opt = click.option(
+    '--projected',
+    'projection',
+    flag_value='projected',
+    default=True,
+    help="Bounds in input's own projected coordinates (the default).")
+
+
 # Clip command
 @click.command(short_help='Clip a raster to given bounds.')
 @click.argument(
@@ -24,23 +39,25 @@
     type=click.Path(exists=True),
     help='Raster dataset to use as a template for bounds')
 @format_opt
+@projection_geographic_opt
+@projection_projected_opt
 @options.creation_options
 @click.pass_context
-def clip(
-        ctx,
-        files,
-        output,
-        bounds,
-        like,
-        driver,
-        creation_options):
-    """Clips a raster using bounds input directly or from a template raster.
+def clip(ctx, files, output, bounds, like, driver, projection,
+         creation_options):
+    """Clips a raster using projected or geographic bounds.
 
     \b
       $ rio clip input.tif output.tif --bounds xmin ymin xmax ymax
       $ rio clip input.tif output.tif --like template.tif
 
-    If using --bounds, values must be in coordinate reference system of input.
+    The values of --bounds are presumed to be from the coordinate
+    reference system of the input dataset unless the --geographic option
+    is used, in which case the values may be longitude and latitude
+    bounds. Either JSON, for example "[west, south, east, north]", or
+    plain text "west south east north" representations of a bounding box
+    are acceptable.
+
     If using --like, bounds will automatically be transformed to match the
     coordinate reference system of the input.
 
@@ -50,7 +67,6 @@ def clip(
       $ rio clip input.tif output.tif --bounds $(fio info features.shp --bounds)
 
     """
-
     from rasterio.warp import transform_bounds
 
     with ctx.obj['env']:
@@ -60,6 +76,8 @@ def clip(
 
         with rasterio.open(input) as src:
             if bounds:
+                if projection == 'geographic':
+                    bounds = transform_bounds('epsg:4326', src.crs, *bounds)
                 if disjoint_bounds(bounds, src.bounds):
                     raise click.BadParameter('must overlap the extent of '
                                              'the input raster',
diff --git a/rasterio/rio/options.py b/rasterio/rio/options.py
--- a/rasterio/rio/options.py
+++ b/rasterio/rio/options.py
@@ -48,6 +48,7 @@
 
 
 import os.path
+import re
 
 import click
 
@@ -76,18 +77,21 @@ def _cb_key_val(ctx, param, value):
         out = {}
         for pair in value:
             if '=' not in pair:
-                raise click.BadParameter("Invalid syntax for KEY=VAL arg: {}".format(pair))
+                raise click.BadParameter(
+                    "Invalid syntax for KEY=VAL arg: {}".format(pair))
             else:
                 k, v = pair.split('=', 1)
                 k = k.lower()
                 v = v.lower()
-                out[k] = v
+                out[k] = None if v in ['none', 'null', 'nil'] else v
         return out
 
+
 def abspath_forward_slashes(path):
     """Return forward-slashed version of os.path.abspath"""
     return '/'.join(os.path.abspath(path).split(os.path.sep))
 
+
 def file_in_handler(ctx, param, value):
     """Normalize ordinary filesystem and VFS paths"""
     try:
@@ -147,6 +151,21 @@ def nodata_handler(ctx, param, value):
     return retval
 
 
+def bounds_handler(ctx, param, value):
+    """Handle different forms of bounds."""
+    retval = from_like_context(ctx, param, value)
+    if retval is None and value is not None:
+        try:
+            value = value.strip(', []')
+            retval = tuple(float(x) for x in re.split('[,\s]+', value))
+            assert len(retval) == 4
+        except:
+            raise click.BadParameter(
+                "{0!r} is not a valid bounding box representation".format(
+                    value))
+    return retval
+
+
 # Singular input file
 file_in_arg = click.argument('INPUT', callback=file_in_handler)
 
@@ -168,9 +187,8 @@ def nodata_handler(ctx, param, value):
 
 # TODO: may be better suited to cligj
 bounds_opt = click.option(
-    '--bounds',
-    nargs=4, type=float, default=None,
-    help='Output bounds: left bottom right top.')
+    '--bounds', default=None, callback=bounds_handler,
+    help='Bounds: "left bottom right top" or "[left, bottom, right, top]".')
 
 dimensions_opt = click.option(
     '--dimensions',
@@ -213,7 +231,7 @@ def nodata_handler(ctx, param, value):
          '--res pixel_width --res pixel_height.')
 
 creation_options = click.option(
-    '--co', 'creation_options',
+    '--co', '--profile', 'creation_options',
     metavar='NAME=VALUE',
     multiple=True,
     callback=_cb_key_val,


Test patch:
diff --git a/tests/test_rio_convert.py b/tests/test_rio_convert.py
--- a/tests/test_rio_convert.py
+++ b/tests/test_rio_convert.py
@@ -13,11 +13,15 @@
 TEST_BBOX = [-11850000, 4804000, -11840000, 4808000]
 
 
+def bbox(*args):
+    return ' '.join([str(x) for x in args])
+
+
 def test_clip_bounds(runner, tmpdir):
     output = str(tmpdir.join('test.tif'))
     result = runner.invoke(
         main_group,
-        ['clip', 'tests/data/shade.tif', output, '--bounds'] + TEST_BBOX)
+        ['clip', 'tests/data/shade.tif', output, '--bounds', bbox(*TEST_BBOX)])
     assert result.exit_code == 0
     assert os.path.exists(output)
 
@@ -25,6 +29,19 @@ def test_clip_bounds(runner, tmpdir):
         assert out.shape == (420, 173)
 
 
+def test_clip_bounds_geographic(runner, tmpdir):
+    output = str(tmpdir.join('test.tif'))
+    result = runner.invoke(
+        main_group,
+        ['clip', 'tests/data/RGB.byte.tif', output, '--geographic', '--bounds',
+         '-78.95864996545055 23.564991210854686 -76.57492370013823 25.550873767433984'])
+    assert result.exit_code == 0
+    assert os.path.exists(output)
+
+    with rasterio.open(output) as out:
+        assert out.shape == (718, 791)
+
+
 def test_clip_like(runner, tmpdir):
     output = str(tmpdir.join('test.tif'))
     result = runner.invoke(
@@ -52,7 +69,7 @@ def test_clip_bounds_disjunct(runner, tmpdir):
     output = str(tmpdir.join('test.tif'))
     result = runner.invoke(
         main_group,
-        ['clip', 'tests/data/shade.tif', output, '--bounds'] + [0, 0, 10, 10])
+        ['clip', 'tests/data/shade.tif', output, '--bounds', bbox(0, 0, 10, 10)])
     assert result.exit_code == 2
     assert '--bounds' in result.output
 
diff --git a/tests/test_rio_features.py b/tests/test_rio_features.py
--- a/tests/test_rio_features.py
+++ b/tests/test_rio_features.py
@@ -12,10 +12,16 @@
 from rasterio.crs import CRS
 from rasterio.rio.main import main_group
 
+
 DEFAULT_SHAPE = (10, 10)
 
 logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
 
+
+def bbox(*args):
+    return ' '.join([str(x) for x in args])
+
+
 def test_mask(runner, tmpdir, basic_feature, basic_image_2x2,
               pixelated_image_file):
 
@@ -476,7 +482,7 @@ def test_rasterize_bounds(tmpdir, runner, basic_feature, basic_image_2x2):
     result = runner.invoke(
         main_group, [
             'rasterize', output, '--dimensions', DEFAULT_SHAPE[0],
-            DEFAULT_SHAPE[1], '--bounds', 0, 10, 10, 0],
+            DEFAULT_SHAPE[1], '--bounds', bbox(0, 10, 10, 0)],
         input=json.dumps(basic_feature))
 
     assert result.exit_code == 0
@@ -582,7 +588,7 @@ def test_rasterize_existing_output(tmpdir, runner, basic_feature):
         main_group, [
             'rasterize', output,
             '--dimensions', DEFAULT_SHAPE[0], DEFAULT_SHAPE[1],
-            '--bounds', 0, 10, 10, 0],
+            '--bounds', bbox(0, 10, 10, 0)],
         input=json.dumps(basic_feature), catch_exceptions=False)
 
     assert result.exit_code == 0
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
@@ -374,7 +374,7 @@ def test_merge_tiny_res_bounds(tiffs):
     inputs.sort()
     runner = CliRunner()
     result = runner.invoke(
-        main_group, ['merge'] + inputs + [outputname, '--res', 2, '--bounds', 1, 0, 5, 4])
+        main_group, ['merge'] + inputs + [outputname, '--res', 2, '--bounds', '1, 0, 5, 4'])
     assert result.exit_code == 0
 
     # Output should be
diff --git a/tests/test_rio_options.py b/tests/test_rio_options.py
--- a/tests/test_rio_options.py
+++ b/tests/test_rio_options.py
@@ -4,7 +4,8 @@
 import click
 import pytest
 
-from rasterio.rio.options import file_in_handler, like_handler, nodata_handler
+from rasterio.rio.options import (
+    bounds_handler, file_in_handler, like_handler, nodata_handler)
 
 
 class MockContext:
@@ -19,6 +20,41 @@ def __init__(self, name):
         self.name = name
 
 
+def test_bounds_handler_3_items():
+    """fail if less than 4 numbers in the bbox"""
+    ctx = MockContext()
+    with pytest.raises(click.BadParameter):
+        bounds_handler(ctx, 'bounds', '1.0 0.0 1.0')
+
+
+def test_bounds_handler_3_items():
+    """fail if there's a non-number in the bbox"""
+    ctx = MockContext()
+    with pytest.raises(click.BadParameter):
+        bounds_handler(ctx, 'bounds', '1.0 surprise! 1.0')
+
+
+def test_bounds_handler_floats():
+    """handle non-JSON bbox"""
+    ctx = MockContext()
+    retval = bounds_handler(ctx, 'bounds', '1.0 0.0 1.0 0.0')
+    assert retval == (1.0, 0.0, 1.0, 0.0)
+
+
+def test_bounds_handler_floats():
+    """handle non-JSON bbox with commas"""
+    ctx = MockContext()
+    retval = bounds_handler(ctx, 'bounds', '1.0, 0.0, 1.0 , 0.0')
+    assert retval == (1.0, 0.0, 1.0, 0.0)
+
+
+def test_bounds_handler_json():
+    """handle JSON bbox"""
+    ctx = MockContext()
+    retval = bounds_handler(ctx, 'bounds', '[1.0, 0.0, 1.0, 0.0]')
+    assert retval == (1.0, 0.0, 1.0, 0.0)
+
+
 def test_file_in_handler_no_vfs_nonexistent():
     """file does not exist"""
     ctx = MockContext()
@@ -38,21 +74,23 @@ def test_file_in_handler_bad_scheme():
     """lolwut scheme is not supported"""
     ctx = MockContext()
     with pytest.raises(click.BadParameter):
-        _ = file_in_handler(ctx, 'INPUT', 'lolwut://bogus')
+        file_in_handler(ctx, 'INPUT', 'lolwut://bogus')
 
 
 def test_file_in_handler_with_vfs_nonexistent():
     """archive does not exist"""
     ctx = MockContext()
     with pytest.raises(click.BadParameter):
-        _ = file_in_handler(
-            ctx, 'INPUT', 'zip://{0}/files.zip!/inputs/RGB.byte.tif'.format(uuid.uuid4()))
+        file_in_handler(
+            ctx, 'INPUT', 'zip://{0}/files.zip!/inputs/RGB.byte.tif'.format(
+                uuid.uuid4()))
 
 
 def test_file_in_handler_with_vfs():
     """vfs file path path is expanded"""
     ctx = MockContext()
-    retval = file_in_handler(ctx, 'INPUT', 'zip://tests/data/files.zip!/inputs/RGB.byte.tif')
+    retval = file_in_handler(
+        ctx, 'INPUT', 'zip://tests/data/files.zip!/inputs/RGB.byte.tif')
     assert retval.endswith('tests/data/files.zip!/inputs/RGB.byte.tif')