723 lines
21 KiB
Python
723 lines
21 KiB
Python
"""
|
|
Utilities to support XYZservices
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.request
|
|
import uuid
|
|
from typing import Callable
|
|
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 as err:
|
|
raise AttributeError(key) from err
|
|
|
|
def __dir__(self):
|
|
return self.keys()
|
|
|
|
def _repr_html_(self, inside=False):
|
|
children = ""
|
|
for key in self:
|
|
if isinstance(self[key], TileProvider):
|
|
obj = "xyzservices.TileProvider"
|
|
else:
|
|
obj = "xyzservices.Bunch"
|
|
uid = str(uuid.uuid4())
|
|
children += f"""
|
|
<li class="xyz-child">
|
|
<input type="checkbox" id="{uid}" class="xyz-checkbox"/>
|
|
<label for="{uid}">{key} <span>{obj}</span></label>
|
|
<div class="xyz-inside">
|
|
{self[key]._repr_html_(inside=True)}
|
|
</div>
|
|
</li>
|
|
"""
|
|
|
|
style = "" if inside else f"<style>{CSS_STYLE}</style>"
|
|
html = f"""
|
|
<div>
|
|
{style}
|
|
<div class="xyz-wrap">
|
|
<div class="xyz-header">
|
|
<div class="xyz-obj">xyzservices.Bunch</div>
|
|
<div class="xyz-name">{len(self)} items</div>
|
|
</div>
|
|
<div class="xyz-details">
|
|
<ul class="xyz-collapsible">
|
|
{children}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
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: str | None = None,
|
|
name: str | None = None,
|
|
requires_token: bool | None = 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) and 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 ``"<insert your access token
|
|
here>"`` 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": "<insert your access token here>",
|
|
... "name": "my_private_provider",
|
|
... }
|
|
... )
|
|
|
|
It is customary to include ``html_attribution`` attribute containing HTML string as
|
|
``'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>
|
|
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) -> TileProvider:
|
|
new = TileProvider(self) # takes a copy preserving the class
|
|
return new
|
|
|
|
def build_url(
|
|
self,
|
|
x: int | str | None = None,
|
|
y: int | str | None = None,
|
|
z: int | str | None = None,
|
|
scale_factor: str | None = None,
|
|
fill_subdomain: bool | None = 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 `'<insert your...'` string in one or more of attributes. When
|
|
specifying a placeholder for the access token, please use the ``"<insert your
|
|
access token here>"`` 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 "<insert your" in val and key in self.url:
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def html_attribution(self):
|
|
if "html_attribution" in self:
|
|
return self["html_attribution"]
|
|
return self["attribution"]
|
|
|
|
def _repr_html_(self, inside=False):
|
|
provider_info = ""
|
|
for key, val in self.items():
|
|
if key != "name":
|
|
provider_info += f"<dt><span>{key}</span></dt><dd>{val}</dd>"
|
|
|
|
style = "" if inside else f"<style>{CSS_STYLE}</style>"
|
|
html = f"""
|
|
<div>
|
|
{style}
|
|
<div class="xyz-wrap">
|
|
<div class="xyz-header">
|
|
<div class="xyz-obj">xyzservices.TileProvider</div>
|
|
<div class="xyz-name">{self.name}</div>
|
|
</div>
|
|
<div class="xyz-details">
|
|
<dl class="xyz-attrs">
|
|
{provider_info}
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
return html
|
|
|
|
@classmethod
|
|
def from_qms(cls, name: str) -> TileProvider:
|
|
"""
|
|
Creates a :class:`TileProvider` object based on the definition from
|
|
the `Quick Map Services <https://qms.nextgis.com/>`__ 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:
|
|
provider = data[provider_name]
|
|
|
|
if "url" in provider:
|
|
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;
|
|
}
|
|
"""
|