initial commit

This commit is contained in:
klein panic
2024-09-29 01:46:07 -04:00
commit ba6e6914d1
7576 changed files with 1356825 additions and 0 deletions

View File

@@ -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")

File diff suppressed because it is too large Load Diff

View File

@@ -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
``'&copy; <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;
}
"""

View File

@@ -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)

View File

@@ -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='&copy; <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
== '&copy; <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

View File

@@ -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)