Repository: SciTools/cartopy

Issue:
Better support colour overrides for Features.
If a Feature defines an `edgecolor` or `facecolor` but the user supplies plain `color` when add the feature to a plot, then which one wins depends on the dictionary and keyword sort order. This makes it more explicit by forcing `color` to override both.

See for example, https://github.com/SciTools/cartopy/issues/803#issuecomment-366686339 for a case where this difference comes up.


Discussion:
> I'd advocate doing this at the final stage of the stylisation (once we have computed the compound style), rather than at the __init__.

The point of doing this now is that it _will_ affect the compound style. If we propagate `color` after, we don't know if that's because the user set it or the feature did. For example, `BORDERS` sets `edgecolor`, so if the user sets `color`, then this will propagate over it. But if a `Feature` used `color` (though none of ours do) and the user set `edgecolor`, then doing it at the final stage will override the user's setting.
> But if a Feature used color (though none of ours do) and the user set edgecolor, then doing it at the final stage will override the user's setting.

The problem is that the behaviour is ambiguous (and we are inheriting that from matplotlib itself).

How about we take the stance that is anybody sets the ```color``` argument at *any point of stylization* (be it in the Feature definition [never in core cartopy!], or later on in the call stack, we **always** apply ``color`` at a higher precedence than ``edgecolor`` and ``facecolor``. As you say, that means once ```color``` is set, ```edgecolor``` and ```facecolor``` can **never** be overridden.

I'm advocating this approach as I don't feel that the following case is particularly consistent with matplotlib's precedence:

```
> f = Feature(..., edgecolor='red', color='black')
> ax.add_feature(f, edgecolor='blue')
```

Should, IMO, produce:

```
===> feature style: {'edgecolor': 'black', 'facecolor': 'black'}
```

Not:

```
===>  feature style: {'edgecolor': 'blue', 'facecolor': 'black'}
```

But:

```
> f = Feature(..., edgecolor='red', color='black')
> ax.add_feature(f, color='pink')
```

should produce

```
{'edgecolor': 'pink', 'facecolor': 'pink'}
```

FWIW, I agree with you completely regarding consistency with mpl - I think were saying that on its own ```f``` style should definitely be ```{'edgecolor': 'black', 'facecolor': 'black'}```.
I'm not sure we can say that we've inherited the problem from Matplotlib, as none of its `add_*` functions allow overriding the properties of the thing that's being added.

I guess from Cartopy's perspective, it doesn't matter because we never use `color` for features, but I don't understand why you would want `color` to _always_ override. If `add_feature` is called with `edgecolor`, I'd expect that to take precedence no matter what the feature originally used.

Given what happened in https://github.com/SciTools/cartopy/issues/803#issuecomment-366686339, an alternative idea is that `add_feature(f, color=...)` would get applied to either of `edgecolor` or `facecolor` as originally supplied to `f`. For example, with `BORDERS`, it'd only apply to `edgecolor` because `facecolor` would generate artifacts. This may be a bit too magic though and forcing users to be explicit might be better.
So I'm a little stuck here. Some other perspectives would be extremely helpful (e.g. @weathergod / @dopplershift / @ajdawson).

Essentially, I'm really uncomfortable with ignoring keywords as is the case with:

```
Feature(..., edgecolor='red', color='black')._kwargs == {'edgecolor': 'black', 'facecolor': 'black'}
```

However, it is pointed out that this is the behaviour of matplotlib (I haven't actually confirmed that myself). I'm very tempted to introduce warnings for this somewhat smelly 💩 behaviour.

Furthermore, this is complicated by the complexity that I know this will introduce to things like #1019, where colorization may be done at multiple levels (FEATURE defaults, and as a callback for functional control of style). 

All of this leads to the next obvious issue of handling stylization aliases (e.g. ``lw`` vs ``linewidth``). My approach so far has been to ignore the issue, but it is probably time to address it.

**Note to self: I've found writing this helpful in clearing up what the issue is that I'm addressing here, hope it has been as useful for you 😃.**

### Proposal

Cartopy needs to gain some functionality to merge/compose styles from one or more dictionaries, and for the composed result to be consistent whether done all at once vs iteratively. 

This suggests we need alias expansion (e.g. ``lw -> linewidth``) and keyword normalization (e.g. ``colour -> edgecolor + facecolor``). This can be the canonical place to document precedence (e.g. whether ``color`` trumps ``[edge|face]color``), and the place to implement bespoke styling (e.g. it should be possible for a Feature to define a ``facecolor`` that unless explicitly overridden, setting a ``color`` will not change the default). This final constraint suggests we need a finalization step.

I don't think https://het.as.utexas.edu/HET/Software/Matplotlib/users/style_sheets.html#composing-styles cuts the mustard in terms of the complexity that I've covered here.

Unless we have explicitly intercept all GeoAxes calls, we need to ensure that we remain consistent with the matplotlib interface. Of course, we are in uncharted territory when it comes to things like ``.add_feature``, ``.add_geometries``, ``.coastlines()`` because they introduce the idea of pre-defined styles that can be overridden.

### API

#### cartopy.mpl.style

Where one goes to find out about how cartopy's matplotlib stylisation works.

#### cartopy.mpl.style.merge

Join together one or more style dictionaries.
This expands aliases and has well defined precedence.

#### cartopy.mpl.style.finalize

Given a single (merged) style dictionary, returns a style dictionary that can be passed on to matplotlib, removing any cartopy implementation details (such as the ability to override precedence for things like facecolor - aka allowing a "facecolor": "never" type value).


# Next steps

 * @QuLogic and I to agree whether to merge and move forwards with this PR as it stands, or to implement a first-pass of the above API
 * Implementing the API above

Ping @pelson .

Resolving patch:
diff --git a/lib/cartopy/feature/__init__.py b/lib/cartopy/feature/__init__.py
--- a/lib/cartopy/feature/__init__.py
+++ b/lib/cartopy/feature/__init__.py
@@ -475,7 +475,7 @@ def intersecting_geometries(self, extent):
 
 
 BORDERS = NaturalEarthFeature('cultural', 'admin_0_boundary_lines_land',
-                              '110m', edgecolor='black', facecolor='none')
+                              '110m', edgecolor='black', facecolor='never')
 """Small scale (1:110m) country boundaries."""
 
 STATES = NaturalEarthFeature('cultural', 'admin_1_states_provinces_lakes',
@@ -483,7 +483,7 @@ def intersecting_geometries(self, extent):
 """Small scale (1:110m) state and province boundaries."""
 
 COASTLINE = NaturalEarthFeature('physical', 'coastline', '110m',
-                                edgecolor='black', facecolor='none')
+                                edgecolor='black', facecolor='never')
 """Small scale (1:110m) coastline, including major islands."""
 
 
@@ -507,5 +507,5 @@ def intersecting_geometries(self, extent):
 
 RIVERS = NaturalEarthFeature('physical', 'rivers_lake_centerlines', '110m',
                              edgecolor=COLORS['water'],
-                             facecolor='none')
+                             facecolor='never')
 """Small scale (1:110m) single-line drainages, including lake centerlines."""
diff --git a/lib/cartopy/mpl/feature_artist.py b/lib/cartopy/mpl/feature_artist.py
--- a/lib/cartopy/mpl/feature_artist.py
+++ b/lib/cartopy/mpl/feature_artist.py
@@ -31,6 +31,7 @@
 import matplotlib.collections
 
 import cartopy.mpl.patch as cpatch
+from .style import merge as style_merge, finalize as style_finalize
 
 
 class _GeomKey(object):
@@ -158,9 +159,8 @@ def draw(self, renderer, *args, **kwargs):
         geoms = self._feature.intersecting_geometries(extent)
 
         # Combine all the keyword args in priority order.
-        prepared_kwargs = dict(self._feature.kwargs)
-        prepared_kwargs.update(self._kwargs)
-        prepared_kwargs.update(kwargs)
+        prepared_kwargs = style_merge(
+                self._feature.kwargs, self._kwargs, kwargs)
 
         # Freeze the kwargs so that we can use them as a dict key. We will
         # need to unfreeze this with dict(frozen) before passing to mpl.
@@ -200,8 +200,7 @@ def draw(self, renderer, *args, **kwargs):
                 style = prepared_kwargs
             else:
                 # Unfreeze, then add the computed style, and then re-freeze.
-                style = dict(prepared_kwargs)
-                style.update(self._styler(geom))
+                style = style_merge(dict(prepared_kwargs), self._styler(geom))
                 style = _freeze(style)
 
             stylised_paths.setdefault(style, []).extend(geom_paths)
@@ -212,11 +211,10 @@ def draw(self, renderer, *args, **kwargs):
         # of style items through to a single PathCollection, but that
         # complexity does not yet justify the effort.
         for style, paths in stylised_paths.items():
+            style = style_finalize(dict(style))
             # Build path collection and draw it.
             c = matplotlib.collections.PathCollection(
-                    paths,
-                    transform=transform,
-                    **dict(style))
+                    paths, transform=transform, **style)
             c.set_clip_path(ax.patch)
             c.set_figure(ax.figure)
             c.draw(renderer)
diff --git a/lib/cartopy/mpl/style.py b/lib/cartopy/mpl/style.py
new file mode 100644
--- /dev/null
+++ b/lib/cartopy/mpl/style.py
@@ -0,0 +1,113 @@
+# (C) British Crown Copyright 2018, Met Office
+#
+# This file is part of cartopy.
+#
+# cartopy is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Lesser General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# cartopy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with cartopy.  If not, see <https://www.gnu.org/licenses/>.
+
+from __future__ import (absolute_import, division, print_function)
+
+"""
+Handles matplotlib styling in a single consistent place.
+
+"""
+import warnings
+
+import six
+
+
+# Define the matplotlib style aliases that cartopy can expand.
+# Note: This should not contain the plural aliases
+# (e.g. linewidths -> linewidth).
+# This is an intended duplication of
+# https://github.com/matplotlib/matplotlib/blob/\
+#   2d2dab511d22b6cc9c812cfbcca6df3f9bf3094a/lib/matplotlib/patches.py#L20-L26
+# Duplication intended to simplify readability, given the small number of
+# aliases.
+_ALIASES = {
+    'lw': 'linewidth',
+    'ls': 'linestyle',
+    'fc': 'facecolor',
+    'ec': 'edgecolor',
+}
+
+
+def merge(*style_dicts):
+    """
+    Merge together multiple matplotlib style dictionaries in a predictable way
+
+    The approach taken is:
+
+        For each style:
+            * Expand aliases, such as "lw" -> "linewidth", but always prefer
+              the full form if over-specified (i.e. lw AND linewidth
+              are both set)
+            * "color" overwrites "facecolor" and "edgecolor" (as per
+              matplotlib), UNLESS facecolor == "never", which will be expanded
+              at finalization to 'none'
+
+    >>> style = merge({"lw": 1, "edgecolor": "black", "facecolor": "never"},
+    ...               {"linewidth": 2, "color": "gray"})
+    >>> sorted(style.items())
+    [('edgecolor', 'gray'), ('facecolor', 'never'), ('linewidth', 2)]
+
+    """
+    style = {}
+    facecolor = None
+
+    for orig_style in style_dicts:
+        this_style = orig_style.copy()
+
+        for alias_from, alias_to in _ALIASES.items():
+            alias = this_style.pop(alias_from, None)
+            if alias_from in orig_style:
+                # n.b. alias_from doesn't trump alias_to
+                # (e.g. 'lw' doesn't trump 'linewidth').
+                this_style.setdefault(alias_to, alias)
+
+        color = this_style.pop('color', None)
+        if 'color' in orig_style:
+            this_style['edgecolor'] = color
+            this_style['facecolor'] = color
+
+        if isinstance(facecolor, six.string_types) and facecolor == 'never':
+            requested_color = this_style.pop('facecolor', None)
+            setting_color = not (isinstance(requested_color, six.string_types)
+                                 and requested_color.lower() == 'none')
+            if (('fc' in orig_style or 'facecolor' in orig_style) and
+                    setting_color):
+                warnings.warn('facecolor will have no effect as it has been '
+                              'defined as "never".')
+        else:
+            facecolor = this_style.get('facecolor', facecolor)
+
+        # Push the remainder of the style into the merged style.
+        style.update(this_style)
+
+    return style
+
+
+def finalize(style):
+    """
+    Update the given matplotlib style according to cartopy's style rules.
+
+    Rules:
+
+        1. A facecolor of 'never' is replaced with 'none'.
+
+    """
+    # Expand 'never' to 'none' if we have it.
+    facecolor = style.get('facecolor', None)
+    if facecolor == 'never':
+        style['facecolor'] = 'none'
+    return style


Test patch:
diff --git a/lib/cartopy/tests/mpl/test_feature_artist.py b/lib/cartopy/tests/mpl/test_feature_artist.py
--- a/lib/cartopy/tests/mpl/test_feature_artist.py
+++ b/lib/cartopy/tests/mpl/test_feature_artist.py
@@ -30,7 +30,7 @@
 import cartopy.mpl.geoaxes as geoaxes
 from cartopy.feature import ShapelyFeature
 from cartopy.mpl.feature_artist import FeatureArtist, _freeze, _GeomKey
-
+from cartopy.mpl import style
 
 @pytest.mark.parametrize("source, expected", [
     [{1: 0}, frozenset({(1, 0)})],
@@ -108,6 +108,7 @@ def test_feature_artist_draw_styler(path_collection_cls, feature):
     geoms = list(feature.geometries())
     style1 = {'facecolor': 'blue', 'edgecolor': 'white'}
     style2 = {'color': 'black', 'linewidth': 1}
+    style2_finalized = style.finalize(style.merge(style2))
 
     def styler(geom):
         if geom == geoms[0]:
@@ -126,7 +127,7 @@ def styler(geom):
     calls = [{'paths': (cached_paths(geoms[0], prj_crs), ),
               'style': dict(linewidth=2, **style1)},
              {'paths': (cached_paths(geoms[1], prj_crs), ),
-              'style': style2}]
+              'style': style2_finalized}]
 
     assert path_collection_cls.call_count == 2
     for expected_call, (actual_args, actual_kwargs) in \
diff --git a/lib/cartopy/tests/mpl/test_style.py b/lib/cartopy/tests/mpl/test_style.py
new file mode 100644
--- /dev/null
+++ b/lib/cartopy/tests/mpl/test_style.py
@@ -0,0 +1,85 @@
+# (C) British Crown Copyright 2018, Met Office
+#
+# This file is part of cartopy.
+#
+# cartopy is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Lesser General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# cartopy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with cartopy.  If not, see <https://www.gnu.org/licenses/>.
+
+from __future__ import (absolute_import, division, print_function)
+
+import pytest
+
+from cartopy.mpl import style
+
+
+d = dict
+
+
+@pytest.mark.parametrize(
+    ('styles', 'expected'),
+    [([], {}),
+     ([{}, {}, {}], {}),
+     ([{}, d(a=2), d(a=1)], d(a=1)),
+     ([d(fc='red')], d(facecolor='red')),
+     ([d(fc='red', color='blue')], d(facecolor='blue', edgecolor='blue')),
+     ([d(fc='red', facecolor='blue')], d(facecolor='blue')),
+     ([d(color='red')],
+      d(edgecolor='red', facecolor='red')),
+     ([d(edgecolor='blue'), d(color='red')],
+      d(edgecolor='red', facecolor='red')),
+     ([d(edgecolor='blue'), d(color='red')],
+      d(edgecolor='red', facecolor='red')),
+     ([d(color='blue'), d(edgecolor='red')],
+      d(edgecolor='red', facecolor='blue')),
+     # Even if you set an edgecolor, color should trump it.
+     ([d(color='blue'), d(edgecolor='red', color='yellow')],
+      d(edgecolor='yellow', facecolor='yellow')),
+     # Support for 'never' being honoured.
+     ([d(facecolor='never'), d(color='yellow')],
+      d(edgecolor='yellow', facecolor='never')),
+     ([d(lw=1, linewidth=2)],
+      d(linewidth=2)),
+     ([d(lw=1, linewidth=2), d(lw=3)],
+      d(linewidth=3)),
+     ([d(color=None), d(facecolor='red')],
+      d(facecolor='red', edgecolor=None)),
+     ([d(linewidth=1), d(lw=None)],
+      d(linewidth=None)),
+     ]
+)
+def test_merge(styles, expected):
+    merged_style = style.merge(*styles)
+    assert merged_style == expected
+
+
+@pytest.mark.parametrize(
+    ('case', 'should_warn'),
+    [[{'fc': 'red'}, True], [{'fc': 'NoNe'}, False], [{'fc': 1}, True]])
+def test_merge_warning(case, should_warn):
+    warn_type = UserWarning if should_warn else None
+    with pytest.warns(warn_type, match=r'defined as \"never\"') as record:
+        style.merge({'facecolor': 'never'}, case)
+    assert len(record) == (1 if should_warn else 0)
+
+
+@pytest.mark.parametrize(
+    ('style_d', 'expected'),
+    [
+     # Support for 'never' being honoured.
+     (d(facecolor='never', edgecolor='yellow'),
+      d(edgecolor='yellow', facecolor='none')),
+    ])
+def test_finalize(style_d, expected):
+    assert style.finalize(style_d) == expected
+    # Double check we are updating in-place
+    assert style_d == expected