initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
from .lib import Bunch, TileProvider # noqa
|
||||
from .providers import providers # noqa
|
||||
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(PackageNotFoundError):
|
||||
__version__ = version("xyzservices")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,722 @@
|
||||
"""
|
||||
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;
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
|
||||
from .lib import _load_json
|
||||
|
||||
data_path = os.path.join(sys.prefix, "share", "xyzservices", "providers.json")
|
||||
|
||||
if os.path.exists(data_path):
|
||||
with open(data_path) as f:
|
||||
json = f.read()
|
||||
else:
|
||||
json = pkgutil.get_data("xyzservices", "data/providers.json")
|
||||
|
||||
providers = _load_json(json)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,271 @@
|
||||
from urllib.error import URLError
|
||||
|
||||
import pytest
|
||||
|
||||
import xyzservices.providers as xyz
|
||||
from xyzservices import Bunch, TileProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_provider():
|
||||
return TileProvider(
|
||||
url="https://myserver.com/tiles/{z}/{x}/{y}.png",
|
||||
attribution="(C) xyzservices",
|
||||
name="my_public_provider",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def retina_provider():
|
||||
return TileProvider(
|
||||
url="https://myserver.com/tiles/{z}/{x}/{y}{r}.png",
|
||||
attribution="(C) xyzservices",
|
||||
name="my_public_provider2",
|
||||
r="@2x",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def silent_retina_provider():
|
||||
return TileProvider(
|
||||
url="https://myserver.com/tiles/{z}/{x}/{y}{r}.png",
|
||||
attribution="(C) xyzservices",
|
||||
name="my_public_retina_provider3",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def private_provider():
|
||||
return TileProvider(
|
||||
url="https://myserver.com/tiles/{z}/{x}/{y}?access_token={accessToken}",
|
||||
attribution="(C) xyzservices",
|
||||
accessToken="<insert your access token here>",
|
||||
name="my_private_provider",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def html_attr_provider():
|
||||
return TileProvider(
|
||||
url="https://myserver.com/tiles/{z}/{x}/{y}.png",
|
||||
attribution="(C) xyzservices",
|
||||
html_attribution='© <a href="https://xyzservices.readthedocs.io">xyzservices</a>', # noqa
|
||||
name="my_public_provider_html",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subdomain_provider():
|
||||
return TileProvider(
|
||||
url="https://{s}.myserver.com/tiles/{z}/{x}/{y}.png",
|
||||
attribution="(C) xyzservices",
|
||||
subdomains="abcd",
|
||||
name="my_subdomain_provider",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_bunch(
|
||||
basic_provider,
|
||||
retina_provider,
|
||||
silent_retina_provider,
|
||||
private_provider,
|
||||
html_attr_provider,
|
||||
subdomain_provider,
|
||||
):
|
||||
return Bunch(
|
||||
basic_provider=basic_provider,
|
||||
retina_provider=retina_provider,
|
||||
silent_retina_provider=silent_retina_provider,
|
||||
private_provider=private_provider,
|
||||
bunched=Bunch(
|
||||
html_attr_provider=html_attr_provider, subdomain_provider=subdomain_provider
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_expect_name_url_attribution():
|
||||
msg = (
|
||||
"The attributes `name`, `url`, and `attribution` are "
|
||||
"required to initialise a `TileProvider`. Please provide "
|
||||
"values for: "
|
||||
)
|
||||
with pytest.raises(AttributeError, match=msg + "`name`, `url`, `attribution`"):
|
||||
TileProvider({})
|
||||
with pytest.raises(AttributeError, match=msg + "`url`, `attribution`"):
|
||||
TileProvider({"name": "myname"})
|
||||
with pytest.raises(AttributeError, match=msg + "`attribution`"):
|
||||
TileProvider({"url": "my_url", "name": "my_name"})
|
||||
with pytest.raises(AttributeError, match=msg + "`attribution`"):
|
||||
TileProvider(url="my_url", name="my_name")
|
||||
|
||||
|
||||
def test_build_url(
|
||||
basic_provider,
|
||||
retina_provider,
|
||||
silent_retina_provider,
|
||||
private_provider,
|
||||
subdomain_provider,
|
||||
):
|
||||
expected = "https://myserver.com/tiles/{z}/{x}/{y}.png"
|
||||
assert basic_provider.build_url() == expected
|
||||
|
||||
expected = "https://myserver.com/tiles/3/1/2.png"
|
||||
assert basic_provider.build_url(1, 2, 3) == expected
|
||||
assert basic_provider.build_url(1, 2, 3, scale_factor="@2x") == expected
|
||||
assert silent_retina_provider.build_url(1, 2, 3) == expected
|
||||
|
||||
expected = "https://myserver.com/tiles/3/1/2@2x.png"
|
||||
assert retina_provider.build_url(1, 2, 3) == expected
|
||||
assert silent_retina_provider.build_url(1, 2, 3, scale_factor="@2x") == expected
|
||||
|
||||
expected = "https://myserver.com/tiles/3/1/2@5x.png"
|
||||
assert retina_provider.build_url(1, 2, 3, scale_factor="@5x") == expected
|
||||
|
||||
expected = "https://myserver.com/tiles/{z}/{x}/{y}?access_token=my_token"
|
||||
assert private_provider.build_url(accessToken="my_token") == expected
|
||||
|
||||
with pytest.raises(ValueError, match="Token is required for this provider"):
|
||||
private_provider.build_url()
|
||||
|
||||
expected = "https://{s}.myserver.com/tiles/{z}/{x}/{y}.png"
|
||||
assert subdomain_provider.build_url(fill_subdomain=False)
|
||||
|
||||
expected = "https://a.myserver.com/tiles/{z}/{x}/{y}.png"
|
||||
assert subdomain_provider.build_url()
|
||||
|
||||
|
||||
def test_requires_token(private_provider, basic_provider):
|
||||
assert private_provider.requires_token() is True
|
||||
assert basic_provider.requires_token() is False
|
||||
|
||||
|
||||
def test_html_repr(basic_provider, retina_provider):
|
||||
provider_strings = [
|
||||
'<div class="xyz-wrap">',
|
||||
'<div class="xyz-header">',
|
||||
'<div class="xyz-obj">xyzservices.TileProvider</div>',
|
||||
'<div class="xyz-name">my_public_provider</div>',
|
||||
'<div class="xyz-details">',
|
||||
'<dl class="xyz-attrs">',
|
||||
"<dt><span>url</span></dt><dd>https://myserver.com/tiles/{z}/{x}/{y}.png</dd>",
|
||||
"<dt><span>attribution</span></dt><dd>(C) xyzservices</dd>",
|
||||
]
|
||||
|
||||
for html_string in provider_strings:
|
||||
assert html_string in basic_provider._repr_html_()
|
||||
|
||||
bunch = Bunch({"first": basic_provider, "second": retina_provider})
|
||||
|
||||
bunch_strings = [
|
||||
'<div class="xyz-obj">xyzservices.Bunch</div>',
|
||||
'<div class="xyz-name">2 items</div>',
|
||||
'<ul class="xyz-collapsible">',
|
||||
'<li class="xyz-child">',
|
||||
"<span>xyzservices.TileProvider</span>",
|
||||
'<div class="xyz-inside">',
|
||||
]
|
||||
|
||||
bunch_repr = bunch._repr_html_()
|
||||
for html_string in provider_strings + bunch_strings:
|
||||
assert html_string in bunch_repr
|
||||
assert bunch_repr.count('<li class="xyz-child">') == 2
|
||||
assert bunch_repr.count('<div class="xyz-wrap">') == 3
|
||||
assert bunch_repr.count('<div class="xyz-header">') == 3
|
||||
|
||||
|
||||
def test_copy(basic_provider):
|
||||
basic2 = basic_provider.copy()
|
||||
assert isinstance(basic2, TileProvider)
|
||||
|
||||
|
||||
def test_callable():
|
||||
# only testing the callable functionality to override a keyword, as we
|
||||
# cannot test the actual providers that need an API key
|
||||
original_key = str(xyz.OpenWeatherMap.CloudsClassic["apiKey"])
|
||||
updated_provider = xyz.OpenWeatherMap.CloudsClassic(apiKey="mykey")
|
||||
assert isinstance(updated_provider, TileProvider)
|
||||
assert "url" in updated_provider
|
||||
assert updated_provider["apiKey"] == "mykey"
|
||||
# check that original provider dict is not modified
|
||||
assert xyz.OpenWeatherMap.CloudsClassic["apiKey"] == original_key
|
||||
|
||||
|
||||
def test_html_attribution_fallback(basic_provider, html_attr_provider):
|
||||
# TileProvider.html_attribution falls back to .attribution if the former not present
|
||||
assert basic_provider.html_attribution == basic_provider.attribution
|
||||
assert (
|
||||
html_attr_provider.html_attribution
|
||||
== '© <a href="https://xyzservices.readthedocs.io">xyzservices</a>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="timeout error", raises=URLError)
|
||||
def test_from_qms():
|
||||
provider = TileProvider.from_qms("OpenStreetMap Standard aka Mapnik")
|
||||
assert isinstance(provider, TileProvider)
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="timeout error", raises=URLError)
|
||||
def test_from_qms_not_found_error():
|
||||
with pytest.raises(ValueError):
|
||||
TileProvider.from_qms("LolWut")
|
||||
|
||||
|
||||
def test_flatten(
|
||||
basic_provider, retina_provider, silent_retina_provider, private_provider
|
||||
):
|
||||
nested_bunch = Bunch(
|
||||
first_bunch=Bunch(first=basic_provider, second=retina_provider),
|
||||
second_bunch=Bunch(first=silent_retina_provider, second=private_provider),
|
||||
)
|
||||
|
||||
assert len(nested_bunch) == 2
|
||||
assert len(nested_bunch.flatten()) == 4
|
||||
|
||||
|
||||
def test_filter(test_bunch):
|
||||
assert len(test_bunch.filter(keyword="private").flatten()) == 1
|
||||
assert len(test_bunch.filter(keyword="public").flatten()) == 4
|
||||
assert len(test_bunch.filter(keyword="{s}").flatten()) == 1
|
||||
assert len(test_bunch.filter(name="retina").flatten()) == 1
|
||||
assert len(test_bunch.filter(requires_token=True).flatten()) == 1
|
||||
assert len(test_bunch.filter(requires_token=False).flatten()) == 5
|
||||
assert len(test_bunch.filter(requires_token=False)) == 4 # check nested structure
|
||||
assert len(test_bunch.filter(keyword="{s}", requires_token=False).flatten()) == 1
|
||||
assert len(test_bunch.filter(name="nonsense").flatten()) == 0
|
||||
|
||||
def custom(provider):
|
||||
if hasattr(provider, "subdomains") and provider.subdomains == "abcd":
|
||||
return True
|
||||
if hasattr(provider, "r"):
|
||||
return True
|
||||
return False
|
||||
|
||||
assert len(test_bunch.filter(function=custom).flatten()) == 2
|
||||
|
||||
|
||||
def test_query_name():
|
||||
options = [
|
||||
"CartoDB Positron",
|
||||
"cartodbpositron",
|
||||
"cartodb-positron",
|
||||
"carto db/positron",
|
||||
"CARTO_DB_POSITRON",
|
||||
"CartoDB.Positron",
|
||||
"Carto,db,positron",
|
||||
]
|
||||
|
||||
for option in options:
|
||||
queried = xyz.query_name(option)
|
||||
assert isinstance(queried, TileProvider)
|
||||
assert queried.name == "CartoDB.Positron"
|
||||
|
||||
with pytest.raises(ValueError, match="No matching provider found"):
|
||||
xyz.query_name("i don't exist")
|
||||
|
||||
# Name with underscore GH124
|
||||
option_with_underscore = "NASAGIBS.ASTER_GDEM_Greyscale_Shaded_Relief"
|
||||
queried = xyz.query_name(option_with_underscore)
|
||||
assert isinstance(queried, TileProvider)
|
||||
assert queried.name == option_with_underscore
|
||||
@@ -0,0 +1,242 @@
|
||||
import os
|
||||
|
||||
import mercantile
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import xyzservices.providers as xyz
|
||||
|
||||
flat_free = xyz.filter(requires_token=False).flatten()
|
||||
|
||||
|
||||
def check_provider(provider):
|
||||
for key in ["attribution", "name"]:
|
||||
assert key in provider
|
||||
assert provider.url.startswith("http")
|
||||
for option in ["{z}", "{y}", "{x}"]:
|
||||
assert option in provider.url
|
||||
|
||||
|
||||
def get_tile(provider):
|
||||
bounds = provider.get("bounds", [[-180, -90], [180, 90]])
|
||||
lat = (bounds[0][0] + bounds[1][0]) / 2
|
||||
lon = (bounds[0][1] + bounds[1][1]) / 2
|
||||
zoom = (provider.get("min_zoom", 0) + provider.get("max_zoom", 20)) // 2
|
||||
tile = mercantile.tile(lon, lat, zoom)
|
||||
z = tile.z
|
||||
x = tile.x
|
||||
y = tile.y
|
||||
return (z, x, y)
|
||||
|
||||
|
||||
def get_response(url):
|
||||
s = requests.Session()
|
||||
a = requests.adapters.HTTPAdapter(max_retries=3)
|
||||
s.mount("http://", a)
|
||||
s.mount("https://", a)
|
||||
try:
|
||||
r = s.get(url, timeout=30)
|
||||
except requests.ConnectionError:
|
||||
pytest.xfail("Timeout.")
|
||||
return r.status_code
|
||||
|
||||
|
||||
def get_test_result(provider, allow_403=True):
|
||||
if provider.get("status"):
|
||||
pytest.xfail("Provider is known to be broken.")
|
||||
|
||||
z, x, y = get_tile(provider)
|
||||
|
||||
try:
|
||||
r = get_response(provider.build_url(z=z, x=x, y=y))
|
||||
assert r == requests.codes.ok
|
||||
except AssertionError:
|
||||
if r == 403 and allow_403:
|
||||
pytest.xfail("Provider not available due to API restrictions (Error 403).")
|
||||
|
||||
elif r == 503:
|
||||
pytest.xfail("Service temporarily unavailable (Error 503).")
|
||||
|
||||
elif r == 502:
|
||||
pytest.xfail("Bad Gateway (Error 502).")
|
||||
|
||||
# check another tiles
|
||||
elif r == 404:
|
||||
# in some cases, the computed tile is not available. trying known tiles.
|
||||
options = [
|
||||
(12, 2154, 1363),
|
||||
(6, 13, 21),
|
||||
(16, 33149, 22973),
|
||||
(0, 0, 0),
|
||||
(2, 6, 7),
|
||||
(6, 21, 31),
|
||||
(6, 21, 32),
|
||||
(6, 21, 33),
|
||||
(6, 22, 31),
|
||||
(6, 22, 32),
|
||||
(6, 22, 33),
|
||||
(6, 23, 31),
|
||||
(6, 23, 32),
|
||||
(6, 23, 33),
|
||||
(9, 259, 181),
|
||||
(12, 2074, 1410),
|
||||
]
|
||||
results = []
|
||||
for o in options:
|
||||
z, x, y = o
|
||||
r = get_response(provider.build_url(z=z, x=x, y=y))
|
||||
results.append(r)
|
||||
if not any(x == requests.codes.ok for x in results):
|
||||
raise ValueError(f"Response code: {r}")
|
||||
else:
|
||||
raise ValueError(f"Response code: {r}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_name", xyz.flatten())
|
||||
def test_minimal_provider_metadata(provider_name):
|
||||
provider = xyz.flatten()[provider_name]
|
||||
check_provider(provider)
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
@pytest.mark.parametrize("name", flat_free)
|
||||
def test_free_providers(name):
|
||||
provider = flat_free[name]
|
||||
if "Stadia" in name:
|
||||
pytest.skip("Stadia doesn't support tile download in this way.")
|
||||
get_test_result(provider)
|
||||
|
||||
|
||||
# test providers requiring API keys. Store API keys in GitHub secrets and load them as
|
||||
# environment variables in CI Action. Note that env variable is loaded as empty on PRs
|
||||
# from a fork.
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
@pytest.mark.parametrize("provider_name", xyz.Thunderforest)
|
||||
def test_thunderforest(provider_name):
|
||||
try:
|
||||
token = os.environ["THUNDERFOREST"]
|
||||
except KeyError:
|
||||
pytest.xfail("Missing API token.")
|
||||
if token == "":
|
||||
pytest.xfail("Token empty.")
|
||||
|
||||
provider = xyz.Thunderforest[provider_name](apikey=token)
|
||||
get_test_result(provider, allow_403=False)
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
@pytest.mark.parametrize("provider_name", xyz.Jawg)
|
||||
def test_jawg(provider_name):
|
||||
try:
|
||||
token = os.environ["JAWG"]
|
||||
except KeyError:
|
||||
pytest.xfail("Missing API token.")
|
||||
if token == "":
|
||||
pytest.xfail("Token empty.")
|
||||
|
||||
provider = xyz.Jawg[provider_name](accessToken=token)
|
||||
get_test_result(provider, allow_403=False)
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
def test_mapbox():
|
||||
try:
|
||||
token = os.environ["MAPBOX"]
|
||||
except KeyError:
|
||||
pytest.xfail("Missing API token.")
|
||||
if token == "":
|
||||
pytest.xfail("Token empty.")
|
||||
|
||||
provider = xyz.MapBox(accessToken=token)
|
||||
get_test_result(provider, allow_403=False)
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
@pytest.mark.parametrize("provider_name", xyz.MapTiler)
|
||||
def test_maptiler(provider_name):
|
||||
try:
|
||||
token = os.environ["MAPTILER"]
|
||||
except KeyError:
|
||||
pytest.xfail("Missing API token.")
|
||||
if token == "":
|
||||
pytest.xfail("Token empty.")
|
||||
|
||||
provider = xyz.MapTiler[provider_name](key=token)
|
||||
get_test_result(provider, allow_403=False)
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
@pytest.mark.parametrize("provider_name", xyz.TomTom)
|
||||
def test_tomtom(provider_name):
|
||||
try:
|
||||
token = os.environ["TOMTOM"]
|
||||
except KeyError:
|
||||
pytest.xfail("Missing API token.")
|
||||
if token == "":
|
||||
pytest.xfail("Token empty.")
|
||||
|
||||
provider = xyz.TomTom[provider_name](apikey=token)
|
||||
get_test_result(provider, allow_403=False)
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
@pytest.mark.parametrize("provider_name", xyz.OpenWeatherMap)
|
||||
def test_openweathermap(provider_name):
|
||||
try:
|
||||
token = os.environ["OPENWEATHERMAP"]
|
||||
except KeyError:
|
||||
pytest.xfail("Missing API token.")
|
||||
if token == "":
|
||||
pytest.xfail("Token empty.")
|
||||
|
||||
provider = xyz.OpenWeatherMap[provider_name](apiKey=token)
|
||||
get_test_result(provider, allow_403=False)
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
@pytest.mark.parametrize("provider_name", xyz.HEREv3)
|
||||
def test_herev3(provider_name):
|
||||
try:
|
||||
token = os.environ["HEREV3"]
|
||||
except KeyError:
|
||||
pytest.xfail("Missing API token.")
|
||||
if token == "":
|
||||
pytest.xfail("Token empty.")
|
||||
|
||||
provider = xyz.HEREv3[provider_name](apiKey=token)
|
||||
get_test_result(provider, allow_403=False)
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
@pytest.mark.parametrize("provider_name", xyz.Stadia)
|
||||
def test_stadia(provider_name):
|
||||
try:
|
||||
token = os.environ["STADIA"]
|
||||
except KeyError:
|
||||
pytest.xfail("Missing API token.")
|
||||
if token == "":
|
||||
pytest.xfail("Token empty.")
|
||||
|
||||
provider = xyz.Stadia[provider_name](api_key=token)
|
||||
provider["url"] = provider["url"] + "?api_key={api_key}"
|
||||
get_test_result(provider, allow_403=False)
|
||||
|
||||
|
||||
@pytest.mark.request
|
||||
@pytest.mark.parametrize("provider_name", xyz.OrdnanceSurvey)
|
||||
def test_os(provider_name):
|
||||
try:
|
||||
token = os.environ["ORDNANCESURVEY"]
|
||||
except KeyError:
|
||||
pytest.xfail("Missing API token.")
|
||||
if token == "":
|
||||
pytest.xfail("Token empty.")
|
||||
|
||||
provider = xyz.OrdnanceSurvey[provider_name](key=token)
|
||||
get_test_result(provider, allow_403=False)
|
||||
|
||||
|
||||
# NOTE: AzureMaps are not tested as their free account is limited to
|
||||
# 5000 downloads (total, not per month)
|
||||
Reference in New Issue
Block a user