"""
Utilities to support XYZservices
"""
from __future__ import annotations
import json
import uuid
import urllib.request
from typing import Optional, Callable, Union
from urllib.parse import quote
QUERY_NAME_TRANSLATION = str.maketrans({x: "" for x in "., -_/"})
class Bunch(dict):
"""A dict with attribute-access
:class:`Bunch` is used to store :class:`TileProvider` objects.
Examples
--------
>>> black_and_white = TileProvider(
... name="My black and white tiles",
... url="https://myserver.com/bw/{z}/{x}/{y}",
... attribution="(C) xyzservices",
... )
>>> colorful = TileProvider(
... name="My colorful tiles",
... url="https://myserver.com/color/{z}/{x}/{y}",
... attribution="(C) xyzservices",
... )
>>> MyTiles = Bunch(BlackAndWhite=black_and_white, Colorful=colorful)
>>> MyTiles
{'BlackAndWhite': {'name': 'My black and white tiles', 'url': \
'https://myserver.com/bw/{z}/{x}/{y}', 'attribution': '(C) xyzservices'}, 'Colorful': \
{'name': 'My colorful tiles', 'url': 'https://myserver.com/color/{z}/{x}/{y}', \
'attribution': '(C) xyzservices'}}
>>> MyTiles.BlackAndWhite.url
'https://myserver.com/bw/{z}/{x}/{y}'
"""
def __getattr__(self, key):
try:
return self.__getitem__(key)
except KeyError:
raise AttributeError(key)
def __dir__(self):
return self.keys()
def _repr_html_(self, inside=False):
children = ""
for key in self.keys():
if isinstance(self[key], TileProvider):
obj = "xyzservices.TileProvider"
else:
obj = "xyzservices.Bunch"
uid = str(uuid.uuid4())
children += f"""
{self[key]._repr_html_(inside=True)}
"""
style = "" if inside else f""
html = f"""
"""
return html
def flatten(self) -> dict:
"""Return the nested :class:`Bunch` collapsed into the one level dictionary.
Dictionary keys are :class:`TileProvider` names (e.g. ``OpenStreetMap.Mapnik``)
and its values are :class:`TileProvider` objects.
Returns
-------
flattened : dict
dictionary of :class:`TileProvider` objects
Examples
--------
>>> import xyzservices.providers as xyz
>>> len(xyz)
36
>>> flat = xyz.flatten()
>>> len(xyz)
207
"""
flat = {}
def _get_providers(provider):
if isinstance(provider, TileProvider):
flat[provider.name] = provider
else:
for prov in provider.values():
_get_providers(prov)
_get_providers(self)
return flat
def filter(
self,
keyword: Optional[str] = None,
name: Optional[str] = None,
requires_token: Optional[bool] = None,
function: Callable[[TileProvider], bool] = None,
) -> Bunch:
"""Return a subset of the :class:`Bunch` matching the filter conditions
Each :class:`TileProvider` within a :class:`Bunch` is checked against one or
more specified conditions and kept if they are satisfied or removed if at least
one condition is not met.
Parameters
----------
keyword : str (optional)
Condition returns ``True`` if ``keyword`` string is present in any string
value in a :class:`TileProvider` object.
The comparison is not case sensitive.
name : str (optional)
Condition returns ``True`` if ``name`` string is present in
the name attribute of :class:`TileProvider` object.
The comparison is not case sensitive.
requires_token : bool (optional)
Condition returns ``True`` if :meth:`TileProvider.requires_token` returns
``True`` (i.e. if the object requires specification of API token).
function : callable (optional)
Custom function taking :class:`TileProvider` as an argument and returns
bool. If ``function`` is given, other parameters are ignored.
Returns
-------
filtered : Bunch
Examples
--------
>>> import xyzservices.providers as xyz
You can filter all free providers (not requiring API token):
>>> free_providers = xyz.filter(requires_token=False)
Or all providers with ``open`` in the name:
>>> open_providers = xyz.filter(name="open")
You can use keyword search to find all providers based on OpenStreetMap data:
>>> osm_providers = xyz.filter(keyword="openstreetmap")
You can combine multiple conditions to find providers based on OpenStreetMap
data that require API token:
>>> osm_locked = xyz.filter(keyword="openstreetmap", requires_token=True)
You can also pass custom function that takes :class:`TileProvider` and returns
boolean value. You can then find all providers with ``max_zoom`` smaller than
18:
>>> def zoom18(provider):
... if hasattr(provider, "max_zoom") and provider.max_zoom < 18:
... return True
... return False
>>> small_zoom = xyz.filter(function=zoom18)
"""
def _validate(provider, keyword, name, requires_token):
cond = []
if keyword is not None:
keyword_match = False
for v in provider.values():
if isinstance(v, str):
if keyword.lower() in v.lower():
keyword_match = True
break
cond.append(keyword_match)
if name is not None:
name_match = False
if name.lower() in provider.name.lower():
name_match = True
cond.append(name_match)
if requires_token is not None:
token_match = False
if provider.requires_token() is requires_token:
token_match = True
cond.append(token_match)
return all(cond)
def _filter_bunch(bunch, keyword, name, requires_token, function):
new = Bunch()
for key, value in bunch.items():
if isinstance(value, TileProvider):
if function is None:
if _validate(
value,
keyword=keyword,
name=name,
requires_token=requires_token,
):
new[key] = value
else:
if function(value):
new[key] = value
else:
filtered = _filter_bunch(
value,
keyword=keyword,
name=name,
requires_token=requires_token,
function=function,
)
if filtered:
new[key] = filtered
return new
return _filter_bunch(
self,
keyword=keyword,
name=name,
requires_token=requires_token,
function=function,
)
def query_name(self, name: str) -> TileProvider:
"""Return :class:`TileProvider` based on the name query
Returns a matching :class:`TileProvider` from the :class:`Bunch` if the ``name``
contains the same letters in the same order as the provider's name irrespective
of the letter case, spaces, dashes and other characters.
See examples for details.
Parameters
----------
name : str
Name of the tile provider. Formatting does not matter.
Returns
-------
match: TileProvider
Examples
--------
>>> import xyzservices.providers as xyz
All these queries return the same ``CartoDB.Positron`` TileProvider:
>>> xyz.query_name("CartoDB Positron")
>>> xyz.query_name("cartodbpositron")
>>> xyz.query_name("cartodb-positron")
>>> xyz.query_name("carto db/positron")
>>> xyz.query_name("CARTO_DB_POSITRON")
>>> xyz.query_name("CartoDB.Positron")
"""
xyz_flat_lower = {
k.translate(QUERY_NAME_TRANSLATION).lower(): v
for k, v in self.flatten().items()
}
name_clean = name.translate(QUERY_NAME_TRANSLATION).lower()
if name_clean in xyz_flat_lower:
return xyz_flat_lower[name_clean]
raise ValueError(f"No matching provider found for the query '{name}'.")
class TileProvider(Bunch):
"""
A dict with attribute-access and that
can be called to update keys
Examples
--------
You can create custom :class:`TileProvider` by passing your attributes to the object
as it would have been a ``dict()``. It is required to always specify ``name``,
``url``, and ``attribution``.
>>> public_provider = TileProvider(
... name="My public tiles",
... url="https://myserver.com/tiles/{z}/{x}/{y}.png",
... attribution="(C) xyzservices",
... )
Alternatively, you can create it from a dictionary of attributes. When specifying a
placeholder for the access token, please use the ``""`` string to ensure that :meth:`~xyzservices.TileProvider.requires_token`
method works properly.
>>> private_provider = TileProvider(
... {
... "url": "https://myserver.com/tiles/{z}/{x}/{y}.png?apikey={accessToken}",
... "attribution": "(C) xyzservices",
... "accessToken": "",
... "name": "my_private_provider",
... }
... )
It is customary to include ``html_attribution`` attribute containing HTML string as
``'© OpenStreetMap
contributors'`` alongisde a plain-text ``attribution``.
You can then fetch all information as attributes:
>>> public_provider.url
'https://myserver.com/tiles/{z}/{x}/{y}.png'
>>> public_provider.attribution
'(C) xyzservices'
To ensure you will be able to use the tiles, you can check if the
:class:`TileProvider` requires a token or API key.
>>> public_provider.requires_token()
False
>>> private_provider.requires_token()
True
You can also generate URL in the required format with or without placeholders:
>>> public_provider.build_url()
'https://myserver.com/tiles/{z}/{x}/{y}.png'
>>> private_provider.build_url(x=12, y=21, z=11, accessToken="my_token")
'https://myserver.com/tiles/11/12/21.png?access_token=my_token'
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
missing = []
for el in ["name", "url", "attribution"]:
if el not in self.keys():
missing.append(el)
if len(missing) > 0:
msg = (
f"The attributes `name`, `url`, "
f"and `attribution` are required to initialise "
f"a `TileProvider`. Please provide values for: "
f'`{"`, `".join(missing)}`'
)
raise AttributeError(msg)
def __call__(self, **kwargs) -> TileProvider:
new = TileProvider(self) # takes a copy preserving the class
new.update(kwargs)
return new
def copy(self, **kwargs) -> TileProvider:
new = TileProvider(self) # takes a copy preserving the class
return new
def build_url(
self,
x: Optional[Union[int, str]] = None,
y: Optional[Union[int, str]] = None,
z: Optional[Union[int, str]] = None,
scale_factor: Optional[str] = None,
fill_subdomain: Optional[bool] = True,
**kwargs,
) -> str:
"""
Build the URL of tiles from the :class:`TileProvider` object
Can return URL with placeholders or the final tile URL.
Parameters
----------
x, y, z : int (optional)
tile number
scale_factor : str (optional)
Scale factor (where supported). For example, you can get double resolution
(512 x 512) instead of standard one (256 x 256) with ``"@2x"``. If you want
to keep a placeholder, pass `"{r}"`.
fill_subdomain : bool (optional, default True)
Fill subdomain placeholder with the first available subdomain. If False, the
URL will contain ``{s}`` placeholder for subdomain.
**kwargs
Other potential attributes updating the :class:`TileProvider`.
Returns
-------
url : str
Formatted URL
Examples
--------
>>> import xyzservices.providers as xyz
>>> xyz.CartoDB.DarkMatter.build_url()
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
>>> xyz.CartoDB.DarkMatter.build_url(x=9, y=11, z=5)
'https://a.basemaps.cartocdn.com/dark_all/5/9/11.png'
>>> xyz.CartoDB.DarkMatter.build_url(x=9, y=11, z=5, scale_factor="@2x")
'https://a.basemaps.cartocdn.com/dark_all/5/9/11@2x.png'
>>> xyz.MapBox.build_url(accessToken="my_token")
'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=my_token'
"""
provider = self.copy()
if x is None:
x = "{x}"
if y is None:
y = "{y}"
if z is None:
z = "{z}"
provider.update(kwargs)
if provider.requires_token():
raise ValueError(
"Token is required for this provider, but not provided. "
"You can either update TileProvider or pass respective keywords "
"to build_url()."
)
url = provider.pop("url")
if scale_factor:
r = scale_factor
provider.pop("r", None)
else:
r = provider.pop("r", "")
if fill_subdomain:
subdomains = provider.pop("subdomains", "abc")
s = subdomains[0]
else:
s = "{s}"
return url.format(x=x, y=y, z=z, s=s, r=r, **provider)
def requires_token(self) -> bool:
"""
Returns ``True`` if the TileProvider requires access token to fetch tiles.
The token attribute name vary and some :class:`TileProvider` objects may require
more than one token (e.g. ``HERE``). The information is deduced from the
presence of `'"`` string to ensure that
:meth:`~xyzservices.TileProvider.requires_token` method works properly.
Returns
-------
bool
Examples
--------
>>> import xyzservices.providers as xyz
>>> xyz.MapBox.requires_token()
True
>>> xyz.CartoDB.Positron
False
We can specify this API key by calling the object or overriding the attribute.
Overriding the attribute will alter existing object:
>>> xyz.OpenWeatherMap.Clouds["apiKey"] = "my-private-api-key"
Calling the object will return a copy:
>>> xyz.OpenWeatherMap.Clouds(apiKey="my-private-api-key")
"""
# both attribute and placeholder in url are required to make it work
for key, val in self.items():
if isinstance(val, str) and "{key}{val}"
style = "" if inside else f""
html = f"""
"""
return html
@classmethod
def from_qms(cls, name: str) -> TileProvider:
"""
Creates a :class:`TileProvider` object based on the definition from
the `Quick Map Services `__ open catalog.
Parameters
----------
name : str
Service name
Returns
-------
:class:`TileProvider`
Examples
--------
>>> from xyzservices.lib import TileProvider
>>> provider = TileProvider.from_qms("OpenTopoMap")
"""
QMS_API_URL = "https://qms.nextgis.com/api/v1/geoservices"
services = json.load(
urllib.request.urlopen(f"{QMS_API_URL}/?search={quote(name)}&type=tms")
)
for service in services:
if service["name"] == name:
break
else:
raise ValueError(f"Service '{name}' not found.")
service_id = service["id"]
service_details = json.load(
urllib.request.urlopen(f"{QMS_API_URL}/{service_id}")
)
return cls(
name=service_details["name"],
url=service_details["url"],
min_zoom=service_details.get("z_min"),
max_zoom=service_details.get("z_max"),
attribution=service_details.get("copyright_text"),
)
def _load_json(f):
data = json.loads(f)
providers = Bunch()
for provider_name in data.keys():
provider = data[provider_name]
if "url" in provider.keys():
providers[provider_name] = TileProvider(provider)
else:
providers[provider_name] = Bunch(
{i: TileProvider(provider[i]) for i in provider}
)
return providers
CSS_STYLE = """
/* CSS stylesheet for displaying xyzservices objects in Jupyter.*/
.xyz-wrap {
--xyz-border-color: var(--jp-border-color2, #ddd);
--xyz-font-color2: var(--jp-content-font-color2, rgba(128, 128, 128, 1));
--xyz-background-color-white: var(--jp-layout-color1, white);
--xyz-background-color: var(--jp-layout-color2, rgba(128, 128, 128, 0.1));
}
html[theme=dark] .xyz-wrap,
body.vscode-dark .xyz-wrap,
body.vscode-high-contrast .xyz-wrap {
--xyz-border-color: #222;
--xyz-font-color2: rgba(255, 255, 255, 0.54);
--xyz-background-color-white: rgba(255, 255, 255, 1);
--xyz-background-color: rgba(255, 255, 255, 0.05);
}
.xyz-header {
padding-top: 6px;
padding-bottom: 6px;
margin-bottom: 4px;
border-bottom: solid 1px var(--xyz-border-color);
}
.xyz-header>div {
display: inline;
margin-top: 0;
margin-bottom: 0;
}
.xyz-obj,
.xyz-name {
margin-left: 2px;
margin-right: 10px;
}
.xyz-obj {
color: var(--xyz-font-color2);
}
.xyz-attrs {
grid-column: 1 / -1;
}
dl.xyz-attrs {
padding: 0 5px 0 5px;
margin: 0;
display: grid;
grid-template-columns: 135px auto;
background-color: var(--xyz-background-color);
}
.xyz-attrs dt,
dd {
padding: 0;
margin: 0;
float: left;
padding-right: 10px;
width: auto;
}
.xyz-attrs dt {
font-weight: normal;
grid-column: 1;
}
.xyz-attrs dd {
grid-column: 2;
white-space: pre-wrap;
word-break: break-all;
}
.xyz-details ul>li>label>span {
color: var(--xyz-font-color2);
padding-left: 10px;
}
.xyz-inside {
display: none;
}
.xyz-checkbox:checked~.xyz-inside {
display: contents;
}
.xyz-collapsible li>input {
display: none;
}
.xyz-collapsible>li>label {
cursor: pointer;
}
.xyz-collapsible>li>label:hover {
color: var(--xyz-font-color2);
}
ul.xyz-collapsible {
list-style: none!important;
padding-left: 20px!important;
}
.xyz-checkbox+label:before {
content: '►';
font-size: 11px;
}
.xyz-checkbox:checked+label:before {
content: '▼';
}
.xyz-wrap {
margin-bottom: 10px;
}
"""