Repository: rasterio/rasterio

Issue:
Profile class to deprecate affine key
This is a common pattern in some rasterio code, ironically intended as a way to prepare for the future by ensuring `Affine` objects get passed as the `transform` kwarg. 

```
    with rasterio.open(src_path) as src:
        opts = src.profile.copy()

    opts['transform'] = opts['affine']
```

With the merger of the `drop-dep` branch in PR #763,  we get a `KeyError: 'affine'`.

Adding `affine` to profile dictionary would maintain backwards compat but would lead to unavoidable warnings when kwarg unpacking with `open(..., **profile)`

**Proposed solution**: Modify the `rasterio.profiles.Profile` class to behave like a dict but deprecate the usage of the `affine` key (Warning on `__getitem__`, AttributeError on `__setitem__`). Then `src.profile` could return an instance of this class instead of a dict.

Continuation of the discussion started here: https://github.com/mapbox/rasterio/pull/763#issuecomment-229737519 but broken out into a separate issue.



Discussion:
a first attempt here: https://github.com/mapbox/rasterio/compare/drop-dep...drop-dep-rasterprofile


Resolving patch:
diff --git a/rasterio/profiles.py b/rasterio/profiles.py
--- a/rasterio/profiles.py
+++ b/rasterio/profiles.py
@@ -1,41 +1,69 @@
 """Raster dataset profiles."""
 
+import warnings
+
+from rasterio.compat import UserDict
 from rasterio.dtypes import uint8
 
 
-class Profile:
+class Profile(UserDict):
     """Base class for Rasterio dataset profiles.
 
-    Subclasses will declare a format driver and driver-specific
-    creation options.
+    Subclasses will declare driver-specific creation options.
     """
 
-    driver = None
     defaults = {}
 
-    def __call__(self, **kwargs):
-        """Return a mapping of keyword args for writing a new datasets.
+    def __init__(self, data={}, **kwds):
+        """Create a new profile based on the class defaults, which are
+        overlaid with items from the `data` dict and keyword arguments."""
+        UserDict.__init__(self)
+        initdata = self.defaults.copy()
+        initdata.update(data)
+        initdata.update(**kwds)
+
+        if 'affine' in initdata and 'transform' in initdata:
+            warnings.warn("affine item is deprecated, use transform only",
+                          DeprecationWarning)
+            del initdata['affine']
+        elif 'affine' in initdata:
+            warnings.warn("affine item is deprecated, use transform instead",
+                          DeprecationWarning)
+            initdata['transform'] = initdata.pop('affine')
+
+        self.data.update(initdata)
+
+    def __getitem__(self, key):
+        """Like normal item access but with affine alias."""
+        if key == 'affine':
+            key = 'transform'
+            warnings.warn("affine item is deprecated, use transform instead",
+                          DeprecationWarning)
+        return self.data[key]
+
+    def __setitem__(self, key, val):
+        """Like normal item setter but forbidding affine item."""
+        if key == 'affine':
+            raise TypeError("affine key is prohibited")
+        self.data[key] = val
 
-        Example:
+    def __call__(self, **kwds):
+        """Return a mapping of keyword args.
 
-            profile = SomeProfile()
-            with rasterio.open('foo.tif', 'w', **profile()) as dst:
-                # Write data ...
+        DEPRECATED.
         """
-        if kwargs.get('driver', self.driver) != self.driver:
-            raise ValueError(
-                "Overriding this profile's driver is not allowed.")
-        profile = self.defaults.copy()
-        profile.update(**kwargs)
-        profile['driver'] = self.driver
+        warnings.warn("__call__() is deprecated, use mapping methods instead",
+                      DeprecationWarning)
+        profile = self.data.copy()
+        profile.update(**kwds)
         return profile
 
 
 class DefaultGTiffProfile(Profile):
-    """A tiled, band-interleaved, LZW-compressed, 8-bit GTiff profile."""
+    """Tiled, band-interleaved, LZW-compressed, 8-bit GTiff."""
 
-    driver = 'GTiff'
     defaults = {
+        'driver': 'GTiff',
         'interleave': 'band',
         'tiled': True,
         'blockxsize': 256,
diff --git a/rasterio/rio/info.py b/rasterio/rio/info.py
--- a/rasterio/rio/info.py
+++ b/rasterio/rio/info.py
@@ -66,7 +66,7 @@ def info(ctx, input, aspect, indent, namespace, meta_member, verbose, bidx,
     try:
         with rasterio.Env(CPL_DEBUG=(verbosity > 2)):
             with rasterio.open(input, mode) as src:
-                info = src.profile
+                info = dict(src.profile)
                 info['shape'] = info['height'], info['width']
                 info['bounds'] = src.bounds
                 proj4 = src.crs.to_string()


Test patch:
diff --git a/tests/test_profile.py b/tests/test_profile.py
--- a/tests/test_profile.py
+++ b/tests/test_profile.py
@@ -1,3 +1,5 @@
+import warnings
+
 import pytest
 
 import rasterio
@@ -6,88 +8,83 @@
 
 
 def test_base_profile():
-    assert Profile()()['driver'] is None
+    assert 'driver' not in Profile()
 
 
 def test_base_profile_kwarg():
-    assert Profile()(foo='bar')['foo'] == 'bar'
+    assert Profile(foo='bar')['foo'] == 'bar'
 
 
-def test_gtiff_profile_format():
+def test_gtiff_profile_format(recwarn):
+    assert DefaultGTiffProfile()['driver'] == 'GTiff'
+    warnings.simplefilter('always')
     assert DefaultGTiffProfile()()['driver'] == 'GTiff'
+    assert len(recwarn) == 1
+    assert recwarn.pop(DeprecationWarning)
 
 
 def test_gtiff_profile_interleave():
-    assert DefaultGTiffProfile()()['interleave'] == 'band'
+    assert DefaultGTiffProfile()['interleave'] == 'band'
 
 
 def test_gtiff_profile_tiled():
-    assert DefaultGTiffProfile()()['tiled'] == True
+    assert DefaultGTiffProfile()['tiled'] is True
 
 
 def test_gtiff_profile_blockxsize():
-    assert DefaultGTiffProfile()()['blockxsize'] == 256
+    assert DefaultGTiffProfile()['blockxsize'] == 256
 
 
 def test_gtiff_profile_blockysize():
-    assert DefaultGTiffProfile()()['blockysize'] == 256
+    assert DefaultGTiffProfile()['blockysize'] == 256
 
 
 def test_gtiff_profile_compress():
-    assert DefaultGTiffProfile()()['compress'] == 'lzw'
+    assert DefaultGTiffProfile()['compress'] == 'lzw'
 
 
 def test_gtiff_profile_nodata():
-    assert DefaultGTiffProfile()()['nodata'] == 0
+    assert DefaultGTiffProfile()['nodata'] == 0
 
 
 def test_gtiff_profile_dtype():
-    assert DefaultGTiffProfile()()['dtype'] == rasterio.uint8
+    assert DefaultGTiffProfile()['dtype'] == rasterio.uint8
 
 
 def test_gtiff_profile_other():
-    assert DefaultGTiffProfile()(count=3)['count'] == 3
+    assert DefaultGTiffProfile(count=3)['count'] == 3
 
 
 def test_gtiff_profile_dtype_override():
-    assert DefaultGTiffProfile()(dtype='uint16')['dtype'] == rasterio.uint16
-
-
-def test_gtiff_profile_protected_driver():
-    """Overriding the driver is not allowed."""
-    with pytest.raises(ValueError):
-        DefaultGTiffProfile()(driver='PNG')
+    assert DefaultGTiffProfile(dtype='uint16')['dtype'] == rasterio.uint16
 
 
 def test_open_with_profile(tmpdir):
     tiffname = str(tmpdir.join('foo.tif'))
-    with rasterio.open(
-            tiffname,
-            'w',
-            **default_gtiff_profile(
-                count=1, width=256, height=256)) as dst:
-        data = dst.read()
+    with rasterio.open(tiffname, 'w', **default_gtiff_profile(
+            count=1, width=256, height=256)) as dst:
+        assert not dst.closed
 
 
 def test_blockxsize_guard(tmpdir):
     """blockxsize can't be greater than image width."""
     tiffname = str(tmpdir.join('foo.tif'))
     with pytest.raises(ValueError):
-        _ = rasterio.open(tiffname, 'w', **default_gtiff_profile(
-                count=1, width=128, height=256))
+        rasterio.open(tiffname, 'w', **default_gtiff_profile(
+            count=1, width=128, height=256))
 
 
 def test_blockysize_guard(tmpdir):
     """blockysize can't be greater than image height."""
     tiffname = str(tmpdir.join('foo.tif'))
     with pytest.raises(ValueError):
-        _ = rasterio.open(tiffname, 'w', **default_gtiff_profile(
-                count=1, width=256, height=128))
+        rasterio.open(tiffname, 'w', **default_gtiff_profile(
+            count=1, width=256, height=128))
 
 
 def test_profile_overlay():
     with rasterio.open('tests/data/RGB.byte.tif') as src:
-        kwds = src.meta
+        kwds = src.profile
     kwds.update(**default_gtiff_profile())
     assert kwds['tiled']
     assert kwds['compress'] == 'lzw'
@@ -99,7 +96,7 @@ def test_dataset_profile_property_tiled(data):
     with rasterio.open('tests/data/shade.tif') as src:
         assert src.profile['blockxsize'] == 256
         assert src.profile['blockysize'] == 256
-        assert src.profile['tiled'] == True
+        assert src.profile['tiled'] is True
 
 
 def test_dataset_profile_property_untiled(data):
@@ -107,7 +104,7 @@ def test_dataset_profile_property_untiled(data):
     with rasterio.open('tests/data/RGB.byte.tif') as src:
         assert 'blockxsize' not in src.profile
         assert 'blockysize' not in src.profile
-        assert src.profile['tiled'] == False
+        assert src.profile['tiled'] is False
 
 
 def test_dataset_profile_creation_kwds(data):
@@ -115,5 +112,42 @@ def test_dataset_profile_creation_kwds(data):
     tiffile = str(data.join('RGB.byte.tif'))
     with rasterio.open(tiffile, 'r+') as src:
         src.update_tags(ns='rio_creation_kwds', foo='bar')
-        assert src.profile['tiled'] == False
+        assert src.profile['tiled'] is False
         assert src.profile['foo'] == 'bar'
+
+
+def test_profile_affine_stashing(recwarn):
+    """Passing affine sets transform, with a warning"""
+    warnings.simplefilter('always')
+    profile = Profile(affine='foo')
+    assert len(recwarn) == 1
+    assert recwarn.pop(DeprecationWarning)
+    assert 'affine' not in profile
+    assert profile['transform'] == 'foo'
+
+
+def test_profile_mixed_error(recwarn):
+    """Warn if both affine and transform are passed"""
+    warnings.simplefilter('always')
+    profile = Profile(affine='foo', transform='bar')
+    assert len(recwarn) == 1
+    assert recwarn.pop(DeprecationWarning)
+    assert 'affine' not in profile
+    assert profile['transform'] == 'bar'
+
+
+def test_profile_affine_alias(recwarn):
+    """affine is an alias for transform, with a warning"""
+    profile = Profile(transform='foo')
+    warnings.simplefilter('always')
+    assert profile['affine'] == 'foo'
+    assert len(recwarn) == 1
+    assert recwarn.pop(DeprecationWarning)
+
+
+def test_profile_affine_set():
+    """TypeError is raised on set of affine item"""
+    profile = Profile()
+    profile['transform'] = 'foo'
+    with pytest.raises(TypeError):
+        profile['affine'] = 'bar'