Repository: rasterio/rasterio

Issue:
Better default rasterio.Env() state
Rasterio imposes some fairly sane GDAL environment defaults, but in a _very_ transparent way:

```python
import rasterio as rio
from rasterio.env import get_gdal_config

options = {}

with rio.Env(**options) as env:
    print(env.options)
    print(get_gdal_config('check_with_invert_proj'))
```

And we don't offer an easy way to disable them:

```python
import rasterio as rio

with rio.Env(**{k: not v for k, v in rio.env.default_options.items()}) as env:
    pass
```

The more time I spend poking at Rasterio's management of GDAL's environment, the more I think think its responsible to transparently offer a sane default environment through `@ensure_env`, but a completely empty GDAL environment when the user instantiates their own `rasterio.Env()`.  Those coming from `osgeo.gdal` may get tripped up initially when they need to set something in the GDAL environment and discover the `rasterio.Env()` they explicitly create is different than the one magically provided by `@ensure_env`, but some really solid documentation should lower that initial hurdle.

Something like this should do it:

```python
class Env:

    @classmethod
    def default_options(cls):
        return {
            'CHECK_WITH_INVERT_PROJ': True,
            'GTIFF_IMPLICIT_JPEG_OVR': False,
            "RASTERIO_ENV": True
        }
    
    @classmethod
    def from_defaults(cls, *args, **kwargs):
        options = Env.default_options()
        options.update(**kwargs)
        return Env(*args, **options)
```

Users desiring the defaults can do:

```python
with Env.from_defaults() as env:
    ...
```

Additional environment options can still be set, and specific defaults can be switched off:

```python
with Env.from_defaults(something='else', GTIFF_IMPLICIT_JPEG_OVR=True) as env:
    ...
```

And `@ensure_env` would create its environment with `Env.from_defaults()`.

The test cleanup mentioned in https://github.com/mapbox/rasterio/issues/1009#issuecomment-293443234 would need to happen first.

Thoughts?


Discussion:
@geowurster sorry about dropping the ball here. I'm trying to digest what you've written today and tomorrow.

Resolving patch:
diff --git a/rasterio/env.py b/rasterio/env.py
--- a/rasterio/env.py
+++ b/rasterio/env.py
@@ -51,12 +51,6 @@ def __init__(self):
 
 log = logging.getLogger(__name__)
 
-# Rasterio defaults
-default_options = {
-    'CHECK_WITH_INVERT_PROJ': True,
-    'GTIFF_IMPLICIT_JPEG_OVR': False,
-    "RASTERIO_ENV": True
-}
 
 class Env(object):
     """Abstraction for GDAL and AWS configuration
@@ -91,6 +85,24 @@ class Env(object):
     for GDAL as needed.
     """
 
+    @classmethod
+    def default_options(cls):
+        """Default configuration options
+
+        Parameters
+        ----------
+        None
+
+        Returns
+        -------
+        dict
+        """
+        return {
+            'CHECK_WITH_INVERT_PROJ': True,
+            'GTIFF_IMPLICIT_JPEG_OVR': False,
+            "RASTERIO_ENV": True
+        }
+
     def __init__(self, session=None, aws_access_key_id=None,
                  aws_secret_access_key=None, aws_session_token=None,
                  region_name=None, profile_name=None, **options):
@@ -101,27 +113,29 @@ def __init__(self, session=None, aws_access_key_id=None,
 
         Parameters
         ----------
-        session: object, optional
-            A boto3 session.
-        aws_access_key_id: string, optional
+        session : optional
+            A boto3 session object.
+        aws_access_key_id : str, optional
             An access key id, as per boto3.
-        aws_secret_access_key: string, optional
+        aws_secret_access_key : str, optional
             A secret access key, as per boto3.
-        aws_session_token: string, optional
+        aws_session_token : str, optional
             A session token, as per boto3.
-        region_name: string, optional
+        region_name : str, optional
             A region name, as per boto3.
-        profile_name: string, optional
+        profile_name : str, optional
             A shared credentials profile name, as per boto3.
-        **options: optional
+        **options : optional
             A mapping of GDAL configuration options, e.g.,
             `CPL_DEBUG=True, CHECK_WITH_INVERT_PROJ=False`.
 
         Returns
         -------
-        A new instance of Env.
+        Env
 
-        Note: We raise EnvError if the GDAL config options
+        Notes
+        -----
+        We raise EnvError if the GDAL config options
         AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY are given. AWS
         credentials are handled exclusively by boto3.
         """
@@ -142,8 +156,37 @@ def __init__(self, session=None, aws_access_key_id=None,
 
         self._creds = None
 
+    @classmethod
+    def from_defaults(cls, *args, **kwargs):
+        """Create an environment with default config options
+
+        Parameters
+        ----------
+        args : optional
+            Positional arguments for Env()
+        kwargs : optional
+            Keyword arguments for Env()
+
+        Returns
+        -------
+        Env
+
+        Notes
+        -----
+        The items in kwargs will be overlaid on the default values.
+        """
+        options = Env.default_options()
+        options.update(**kwargs)
+        return Env(*args, **options)
+
     @property
     def is_credentialized(self):
+        """Test for existence of cloud credentials
+
+        Returns
+        -------
+        bool
+        """
         return bool(self._creds)
 
     def credentialize(self):
@@ -151,6 +194,10 @@ def credentialize(self):
 
         Note well: this method is a no-op if the GDAL environment
         already has credentials, unless session is not None.
+
+        Returns
+        -------
+        None
         """
         if not hascreds():
             import boto3
@@ -199,8 +246,7 @@ def __enter__(self):
             # defined.  This MUST happen before calling 'defenv()'.
             local._discovered_options = {}
             # Don't want to reinstate the "RASTERIO_ENV" option.
-            probe_env = {k for k in default_options if k != "RASTERIO_ENV"}
-            probe_env |= set(self.options.keys())
+            probe_env = {k for k in self.options.keys() if k != "RASTERIO_ENV"}
             for key in probe_env:
                 val = get_gdal_config(key, normalize=False)
                 if val is not None:
@@ -246,12 +292,7 @@ def defenv(**options):
     else:
         log.debug("No GDAL environment exists")
         local._env = GDALEnv()
-        # first set default options, then add user options
-        set_options = {}
-        for d in (default_options, options):
-            for (k, v) in d.items():
-                set_options[k] = v
-        local._env.update_config_options(**set_options)
+        local._env.update_config_options(**options)
         log.debug(
             "New GDAL environment %r created", local._env)
     local._env.start()
@@ -299,14 +340,14 @@ def delenv():
 def ensure_env(f):
     """A decorator that ensures an env exists before a function
     calls any GDAL C functions."""
-    if local._env:
-        return f
-    else:
-        @wraps(f)
-        def wrapper(*args, **kwds):
-            with Env():
+    @wraps(f)
+    def wrapper(*args, **kwds):
+        if local._env:
+            return f(*args, **kwds)
+        else:
+            with Env.from_defaults():
                 return f(*args, **kwds)
-        return wrapper
+    return wrapper
 
 
 @attr.s(slots=True)
@@ -476,8 +517,8 @@ def wrapper(*args, **kwds):
                     elif full_kwds[param] in values:
                         raise GDALVersionError(
                             'parameter "{0}={1}" requires '
-                            'GDAL {2} {3}{4}'.format(param, full_kwds[param],
-                                                  inequality, version, reason))
+                            'GDAL {2} {3}{4}'.format(
+                                param, full_kwds[param], inequality, version, reason))
 
             return f(*args, **kwds)
 


Test patch:
diff --git a/tests/test_env.py b/tests/test_env.py
--- a/tests/test_env.py
+++ b/tests/test_env.py
@@ -9,8 +9,8 @@
 
 import rasterio
 from rasterio._env import del_gdal_config, get_gdal_config, set_gdal_config
-from rasterio.env import defenv, delenv, getenv, setenv, ensure_env
-from rasterio.env import default_options, GDALVersion, require_gdal_version
+from rasterio.env import Env, defenv, delenv, getenv, setenv, ensure_env
+from rasterio.env import GDALVersion, require_gdal_version
 from rasterio.errors import EnvError, RasterioIOError, GDALVersionError
 from rasterio.rio.main import main_group
 
@@ -81,8 +81,7 @@ def test_env_accessors(gdalenv):
     """High level GDAL env access."""
     defenv()
     setenv(foo='1', bar='2')
-    expected = default_options.copy()
-    expected.update({'foo': '1', 'bar': '2'})
+    expected = {'foo': '1', 'bar': '2'}
     items = getenv()
     assert items == rasterio.env.local._env.options
     # Comparison below requires removal of GDAL_DATA.
@@ -122,9 +121,20 @@ def test_no_aws_gdal_config(gdalenv):
         rasterio.Env(AWS_SECRET_ACCESS_KEY='y')
 
 
+def test_env_empty(gdalenv):
+    """Empty env has no defaults"""
+    env = rasterio.Env(foo='x')
+    assert env.options['foo'] == 'x'
+    assert not env.context_options
+    with env:
+        assert get_gdal_config('CHECK_WITH_INVERT_PROJ') is None
+        assert get_gdal_config('GTIFF_IMPLICIT_JPEG_OVR') is None
+        assert get_gdal_config("RASTERIO_ENV") is None
+
+
 def test_env_defaults(gdalenv):
     """Test env defaults."""
-    env = rasterio.Env(foo='x')
+    env = rasterio.Env.from_defaults(foo='x')
     assert env.options['foo'] == 'x'
     assert not env.context_options
     with env:
@@ -163,11 +173,10 @@ def test_with_aws_session_credentials(gdalenv):
     env = rasterio.Env(
         aws_access_key_id='id', aws_secret_access_key='key',
         aws_session_token='token', region_name='null-island-1')
-    expected = default_options.copy()
     with env:
-        expected.update({
+        expected = {
             'AWS_ACCESS_KEY_ID': 'id', 'AWS_REGION': 'null-island-1',
-            'AWS_SECRET_ACCESS_KEY': 'key', 'AWS_SESSION_TOKEN': 'token'})
+            'AWS_SECRET_ACCESS_KEY': 'key', 'AWS_SESSION_TOKEN': 'token'}
         items = getenv()
         # Comparison below requires removal of GDAL_DATA.
         items.pop('GDAL_DATA', None)
@@ -323,7 +332,7 @@ def test_ensure_defaults_teardown(gdalenv):
     """
 
     def _check_defaults():
-        for key in default_options.keys():
+        for key in Env.default_options().keys():
             assert get_gdal_config(key) is None
 
     _check_defaults()
diff --git a/tests/test_warp.py b/tests/test_warp.py
--- a/tests/test_warp.py
+++ b/tests/test_warp.py
@@ -21,7 +21,7 @@
 
 
 gdal_version = GDALVersion.runtime()
-logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
+logging.basicConfig(level=logging.DEBUG)
 
 
 DST_TRANSFORM = Affine(300.0, 0.0, -8789636.708,