Repository: bokeh/bokeh

Issue:
add tile providers for OpenStreetMap and ESRI Imagery
Hello,

**TL;DR**
This issue is to initiate a pull request to add the following tile providers to the Bokeh file **`./bokeh/tile_providers.py`:**
- OpenStreetMaps map tile provider
- Wikimedia / Wikipedia map tile provider (via OSM)
- ESRI Imagery with satellite imagery


**Background:**
In my search for a Google Maps replacement, the current Bokeh has tile providers are a good start but the Stamen and Carto tiles do not provide a combination of:
- ability for close zoom-in (map data not available)
- detail at high zoom (it's just blank, or not descriptive)
- satellite imagery

**Licensing:**
The [OpenStreetMap data](www.openstreetmap.org) is an excellent map-style alternative and the ESRI Imagery tiles are excellent satellite image replacements. OSM is entirely open-source. Esri has [generous open-source](https://www.esri.com/en-us/arcgis/open-vision/standards/open-source) and [open-data policies](https://www.esri.com/en-us/arcgis/open-vision/standards/open-data). Neither of these require api keys or registration, although responsible usage is, indeed, encouraged and enforced. Unusually high usage places a penalty on volunteer and donated hardware and network services.

But, for simple basemap overlays (underlays), prototyping for research purposes, or usage in other open-source projects, these two are outstanding.

**Rationale:**
Both of these tile providers, and others, are already integrated into [PyViz](http://pyviz.org),  [Holoviews](http://holoviews.org/), and [GeoViews](http://geoviews.org/), all of which rely on Bokeh as an underlying library. Rather than adding layers (and moving to Anaconda package management) to access those map tile providers, it seems Bokeh could (should) have these maps available directly.

**Example:**
The screenshot at this link shows multiple failed or poorly displaying zoomed-in options from the fine work presented at the [GeoViews page on working with Bokeh](http://geoviews.org/user_guide/Working_with_Bokeh.html):

https://discourse.bokeh.org/t/open-source-tile-provider-replacement-for-google-maps/4462


Marc Compere




Discussion:
@comperem This seems like a great addition, then. Do you think you could submit the PR to add new instances to `bokeh.tile_providers`?
Yes, certainly. The PR process is not entirely obvious. I've gotten ssh keys loaded into gitlab, was able to get a good authentication response from `ssh -T git@github.com`, so that seems to be working.

I cloned the main bokeh repository with: `git clone git@github.com:bokeh/bokeh.git`

Then made the necessary changes to `tile_providers.py`.

Then `git add bokeh/tile_providers.py`,

Then `git commit -a` and ctrl-X with a descriptive string and `git status` says I'm ahead of origin/master by 1 commit.

It seems I'm ready for the `git push -u origin master` but I'm getting a permissions error:
    `ERROR: Permission to bokeh/bokeh.git denied to comperem.`

Some how I skipped the pull-request process from the web interface. I'm not exactly sure where I went wrong.

Any ideas? 
@comperem Almost there :) Instead of cloning the main repo (which you will not have permissions to push to) you need to first *fork* the repo in to your own GH account. If you look in the very upper right of the page there is a button to fork the repo. Once this is done, clone your fork, and push the change there (you will have permission to since the fork is in your account). Finally, on the main page:

https://github.com/bokeh/bokeh

There is a "New Pull Request" button where you can specify that you would like for us to pull changed from your fork into the master branch of this main repo. Let me know if I can elaborate on anything!

Resolving patch:
diff --git a/bokeh/tile_providers.py b/bokeh/tile_providers.py
--- a/bokeh/tile_providers.py
+++ b/bokeh/tile_providers.py
@@ -165,6 +165,19 @@ class _TileProvidersModule(types.ModuleType):
         'under %s.'
     )
 
+    _OSM_ATTRIBTION = (
+        '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+    )
+
+    _WIKIMEDIA_ATTRIBUTION = (
+        '&copy; <a href="https://foundation.wikimedia.org/wiki/Maps_Terms_of_Use">Wikimedia Maps</a> contributors'
+    )
+
+    _ESRI_IMAGERY_ATTRIBUTION = (
+        '&copy; <a href="http://downloads.esri.com/ArcGISOnline/docs/tou_summary.pdf">Esri</a>, '
+        'Earthstar Geographics'
+    )
+
     _SERVICE_URLS = dict(
         CARTODBPOSITRON='https://tiles.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
         CARTODBPOSITRON_RETINA='https://tiles.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
@@ -173,6 +186,9 @@ class _TileProvidersModule(types.ModuleType):
         STAMEN_TONER='http://tile.stamen.com/toner/{Z}/{X}/{Y}.png',
         STAMEN_TONER_BACKGROUND='http://tile.stamen.com/toner-background/{Z}/{X}/{Y}.png',
         STAMEN_TONER_LABELS='http://tile.stamen.com/toner-labels/{Z}/{X}/{Y}.png',
+        OSM='https://c.tile.openstreetmap.org/{Z}/{X}/{Y}.png',
+        WIKIMEDIA='https://maps.wikimedia.org/osm-intl/{Z}/{X}/{Y}@2x.png',
+        ESRI_IMAGERY='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{Z}/{Y}/{X}.jpg'
     )
 
     _STAMEN_ATTRIBUTION_URLS = dict(
@@ -186,6 +202,7 @@ class _TileProvidersModule(types.ModuleType):
     Vendors = enumeration('CARTODBPOSITRON', 'CARTODBPOSITRON_RETINA',
                           'STAMEN_TERRAIN', 'STAMEN_TERRAIN_RETINA', 'STAMEN_TONER',
                           'STAMEN_TONER_BACKGROUND', 'STAMEN_TONER_LABELS',
+                          'OSM','WIKIMEDIA','ESRI_IMAGERY',
                           case_sensitive=True)
 
     def get_provider(self, provider_name):
@@ -205,6 +222,12 @@ def get_provider(self, provider_name):
             attribution = self._CARTO_ATTRIBUTION
         elif selected_provider.startswith('STAMEN'):
             attribution = self._STAMEN_ATTRIBUTION % self._STAMEN_ATTRIBUTION_URLS[selected_provider]
+        elif selected_provider.startswith('OSM'):
+            attribution = self._OSM_ATTRIBTION
+        elif selected_provider.startswith('WIKIMEDIA'):
+            attribution = self._WIKIMEDIA_ATTRIBUTION
+        elif selected_provider.startswith('ESRI_IMAGERY'):
+            attribution = self._ESRI_IMAGERY_ATTRIBUTION
         else:
             raise ValueError('Can not retrieve attribution for %s' % selected_provider)
         return WMTSTileSource(url=url, attribution=attribution)
@@ -218,6 +241,9 @@ def get_provider(self, provider_name):
     STAMEN_TONER = Vendors.STAMEN_TONER
     STAMEN_TONER_BACKGROUND = Vendors.STAMEN_TONER_BACKGROUND
     STAMEN_TONER_LABELS = Vendors.STAMEN_TONER_LABELS
+    OSM = Vendors.OSM
+    WIKIMEDIA = Vendors.WIKIMEDIA
+    ESRI_IMAGERY = Vendors.ESRI_IMAGERY
 
 #-----------------------------------------------------------------------------
 # Code
@@ -233,6 +259,9 @@ def get_provider(self, provider_name):
     'STAMEN_TONER',
     'STAMEN_TONER_BACKGROUND',
     'STAMEN_TONER_LABELS',
+    'OSM',
+    'WIKIMEDIA',
+    'ESRI_IMAGERY',
     'get_provider',
     'Vendors'
 )


Test patch:
diff --git a/tests/unit/bokeh/test_tile_providers.py b/tests/unit/bokeh/test_tile_providers.py
--- a/tests/unit/bokeh/test_tile_providers.py
+++ b/tests/unit/bokeh/test_tile_providers.py
@@ -34,6 +34,9 @@
     'STAMEN_TONER',
     'STAMEN_TONER_BACKGROUND',
     'STAMEN_TONER_LABELS',
+    'OSM',
+    'WIKIMEDIA',
+    'ESRI_IMAGERY',
     'get_provider',
     'Vendors'
 )
@@ -59,6 +62,18 @@
     'STAMEN_TONER_LABELS':     '<a href="https://www.openstreetmap.org/copyright">ODbL</a>',
 }
 
+_OSM_URLS = {
+    'OSM':                     'https://c.tile.openstreetmap.org/{Z}/{X}/{Y}.png'
+}
+
+_WIKIMEDIA_URLS = {
+    'WIKIMEDIA':               'https://maps.wikimedia.org/osm-intl/{Z}/{X}/{Y}@2x.png'
+}
+
+_ESRI_URLS = {
+    'ESRI_IMAGERY':            'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{Z}/{Y}/{X}.jpg'
+}
+
 #-----------------------------------------------------------------------------
 # General API
 #-----------------------------------------------------------------------------
@@ -115,13 +130,80 @@ def test_copies(self, name):
         p2 = bt.get_provider(getattr(bt, name))
         assert p1 is not p2
 
+@pytest.mark.parametrize('name', ['OSM'])
+@pytest.mark.unit
+class Test_OsmProvider(object):
+    def test_type(self, name):
+        p = getattr(bt, name)
+        assert isinstance(p, str)
+
+    def test_url(self, name):
+        p = bt.get_provider(getattr(bt, name))
+        assert p.url == _OSM_URLS[name]
+
+    def test_attribution(self, name):
+        p = bt.get_provider(getattr(bt, name))
+        assert p.attribution == (
+            '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+        )
+
+    def test_copies(self, name):
+        p1 = bt.get_provider(getattr(bt, name))
+        p2 = bt.get_provider(getattr(bt, name))
+        assert p1 is not p2
+
+@pytest.mark.parametrize('name', ['WIKIMEDIA'])
+@pytest.mark.unit
+class Test_WikimediaProvider(object):
+    def test_type(self, name):
+        p = getattr(bt, name)
+        assert isinstance(p, str)
+
+    def test_url(self, name):
+        p = bt.get_provider(getattr(bt, name))
+        assert p.url == _WIKIMEDIA_URLS[name]
+
+    def test_attribution(self, name):
+        p = bt.get_provider(getattr(bt, name))
+        assert p.attribution == (
+            '&copy; <a href="https://foundation.wikimedia.org/wiki/Maps_Terms_of_Use">Wikimedia Maps</a> contributors'
+        )
+
+    def test_copies(self, name):
+        p1 = bt.get_provider(getattr(bt, name))
+        p2 = bt.get_provider(getattr(bt, name))
+        assert p1 is not p2
+
+@pytest.mark.parametrize('name', ['ESRI_IMAGERY'])
+@pytest.mark.unit
+class Test_EsriProvider(object):
+    def test_type(self, name):
+        p = getattr(bt, name)
+        assert isinstance(p, str)
+
+    def test_url(self, name):
+        p = bt.get_provider(getattr(bt, name))
+        assert p.url == _ESRI_URLS[name]
+
+    def test_attribution(self, name):
+        p = bt.get_provider(getattr(bt, name))
+        assert p.attribution == (
+            '&copy; <a href="http://downloads.esri.com/ArcGISOnline/docs/tou_summary.pdf">Esri</a>, '
+            'Earthstar Geographics'
+        )
+    def test_copies(self, name):
+        p1 = bt.get_provider(getattr(bt, name))
+        p2 = bt.get_provider(getattr(bt, name))
+        assert p1 is not p2
+
+
 
 @pytest.mark.unit
 class Test_GetProvider(object):
 
     @pytest.mark.parametrize('name', ['CARTODBPOSITRON', 'CARTODBPOSITRON_RETINA', 'STAMEN_TERRAIN',
                                       'STAMEN_TERRAIN_RETINA', 'STAMEN_TONER', 'STAMEN_TONER_BACKGROUND',
-                                      'STAMEN_TONER_LABELS', ])
+                                      'STAMEN_TONER_LABELS', 'OSM', 'WIKIMEDIA', 'ESRI_IMAGERY', ])
     def test_get_provider(self, name):
         assert name in bt.Vendors
         enum_member = getattr(bt.Vendors, name)