Repository: dask/dask

Issue:
Config parameter 'num_workers' can't be set with environment variables
In a recent commit the `num_workers` configuration setting was adding to multiprocessing/threaded schedulers. However, it uses an underscore when getting the value from the configuration object (`num_workers`). This means that the environment variable `DASK_NUM_WORKERS` can't be used to set it as environment variables have underscores converted to hyphens (`num-workers`).

The commit in question (@jrbourbeau @mrocklin ): https://github.com/dask/dask/commit/abfae43c808ad15714df8033c8be3176b1595644

Simple example proving that what it is doing won't work:

```bash
DASK_NUM_WORKERS=4 python -c "import dask; print(dask.config.get('num_workers', None)); print(dask.config.get('num-workers', None))"
None
4
```

Is num workers not supposed to be configurable from an environment variable? Otherwise, should it be changed to `num-workers` in the threaded and multiprocess schedulers?


Discussion:
I wonder if `dask.config.get` should normalize underscores and hyphens to the same value.
Given that we support 

```python
with dask.config.set(num_workers=10, scheduler='threading'):
    ...
```

and setting a `DASK_SCHEDULER` environment variable is supported, it seems natural to also support setting the number of workers with `DASK_NUM_WORKERS`. 

One potential solution that comes to mind would be to have `dask.config.set` replace `'_'` with `'-'` like is done with `DASK_` environment variables. 

I'm testing this locally now to see if any problems arise 
Thinking about this more, we may want to have both `get` and `set` normalize underscores and hyphens to the same value. If only `get` normalizes, then you have the situation 

```python
import dask

# Assume DASK_NUM_WORKERS has been set to 2

with dask.config.set(num_worker=5):
    # Will use 2 workers
```

If only `set` normalizes then existing code like `dask.config.get('rerun_exceptions_locally')` or `dask.config.get('delayed_pure')` will break. 

When `get` and `set` both have the same normalization, both the above problems are avoided. 

Resolving patch:
diff --git a/dask/config.py b/dask/config.py
--- a/dask/config.py
+++ b/dask/config.py
@@ -9,7 +9,7 @@
 except ImportError:
     yaml = None
 
-from .compatibility import makedirs, builtins, Mapping
+from .compatibility import makedirs, builtins, Mapping, string_types
 
 
 no_default = '__no_default__'
@@ -102,6 +102,38 @@ def merge(*dicts):
     return result
 
 
+def normalize_key(key):
+    """ Replaces underscores with hyphens in string keys
+
+    Parameters
+    ----------
+    key : string, int, or float
+        Key to assign.
+    """
+    if isinstance(key, string_types):
+        key = key.replace('_', '-')
+    return key
+
+
+def normalize_nested_keys(config):
+    """ Replaces underscores with hyphens for keys for a nested Mapping
+
+    Examples
+    --------
+    >>> a = {'x': 1, 'y_1': {'a_2': 2}}
+    >>> normalize_nested_keys(a)
+    {'x': 1, 'y-1': {'a-2': 2}}
+    """
+    config_norm = {}
+    for key, value in config.items():
+        if isinstance(value, Mapping):
+            value = normalize_nested_keys(value)
+        key_norm = normalize_key(key)
+        config_norm[key_norm] = value
+
+    return config_norm
+
+
 def collect_yaml(paths=paths):
     """ Collect configuration from yaml files
 
@@ -127,6 +159,7 @@ def collect_yaml(paths=paths):
     for path in file_paths:
         with open(path) as f:
             data = yaml.load(f.read()) or {}
+            data = normalize_nested_keys(data)
             configs.append(data)
 
     return configs
@@ -149,7 +182,8 @@ def collect_env(env=None):
     d = {}
     for name, value in env.items():
         if name.startswith('DASK_'):
-            varname = name[5:].lower().replace('__', '.').replace('_', '-')
+            varname = name[5:].lower().replace('__', '.')
+            varname = normalize_key(varname)
             try:
                 d[varname] = ast.literal_eval(value)
             except (SyntaxError, ValueError):
@@ -285,16 +319,16 @@ def _assign(cls, keys, value, d, old=None, path=[]):
         path: List[str]
             Used internally to hold the path of old values
         """
+        key = normalize_key(keys[0])
         if len(keys) == 1:
             if old is not None:
-                path_key = tuple(path + [keys[0]])
-                if keys[0] in d:
-                    old[path_key] = d[keys[0]]
+                path_key = tuple(path + [key])
+                if key in d:
+                    old[path_key] = d[key]
                 else:
                     old[path_key] = '--delete--'
-            d[keys[0]] = value
+            d[key] = value
         else:
-            key = keys[0]
             if key not in d:
                 d[key] = {}
                 if old is not None:
@@ -392,6 +426,7 @@ def get(key, default=no_default, config=config):
     keys = key.split('.')
     result = config
     for k in keys:
+        k = normalize_key(k)
         try:
             result = result[k]
         except (TypeError, IndexError, KeyError):
diff --git a/dask/context.py b/dask/context.py
--- a/dask/context.py
+++ b/dask/context.py
@@ -65,7 +65,7 @@ def globalmethod(default=None, key=None, falsey=None):
 class GlobalMethod(object):
     def __init__(self, default, key, falsey=None):
         self._default = default
-        self._key = key
+        self._key = config.normalize_key(key)
         self._falsey = falsey
 
     def __get__(self, instance, owner=None):


Test patch:
diff --git a/dask/tests/test_config.py b/dask/tests/test_config.py
--- a/dask/tests/test_config.py
+++ b/dask/tests/test_config.py
@@ -6,7 +6,8 @@
 import dask.config
 from dask.config import (update, merge, collect, collect_yaml, collect_env,
                          get, ensure_file, set, config, rename,
-                         update_defaults, refresh, expand_environment_variables)
+                         update_defaults, refresh, expand_environment_variables,
+                         normalize_key, normalize_nested_keys)
 from dask.utils import tmpfile
 
 
@@ -297,3 +298,42 @@ def test_expand_environment_variables(inp, out):
         assert expand_environment_variables(inp) == out
     finally:
         del os.environ['FOO']
+
+
+@pytest.mark.parametrize('inp,out', [
+    ('custom_key', 'custom-key'),
+    ('custom-key', 'custom-key'),
+    (1, 1),
+    (2.3, 2.3)
+])
+def test_normalize_key(inp, out):
+    assert normalize_key(inp) == out
+
+
+def test_normalize_nested_keys():
+    config = {'key_1': 1,
+              'key_2': {'nested_key_1': 2},
+              'key_3': 3
+              }
+    expected = {'key-1': 1,
+                'key-2': {'nested-key-1': 2},
+                'key-3': 3
+                }
+    assert normalize_nested_keys(config) == expected
+
+
+def test_env_var_normalization(monkeypatch):
+    value = 3
+    monkeypatch.setenv('DASK_A_B', value)
+    d = {}
+    dask.config.refresh(config=d)
+    assert get('a_b', config=d) == value
+    assert get('a-b', config=d) == value
+
+
+@pytest.mark.parametrize('key', ['custom_key', 'custom-key'])
+def test_get_set_roundtrip(key):
+    value = 123
+    with dask.config.set({key: value}):
+        assert dask.config.get('custom_key') == value
+        assert dask.config.get('custom-key') == value