Repository: geopandas/xyzservices

Issue:
Add `search` or `filter` method
We have over 200 providers at this moment and it may be helpful to have a function that is able to filter providers based on some criteria. I would imagine keywords (e.g. I want all `HERE` maps) or specs (only those that do not need a token).

API-wise, maybe something like:

```py
Bunch.filter(keyword=None, name=None, requires_token=None...) # maybe some other (zoom, bounds?)
```

It would return a smaller Bunch or an individual tile provider if `name==TileProvider.name`.

Discussion:

Patch:
diff --git a/doc/source/api.rst b/doc/source/api.rst
index d2581f2..66b5f52 100644
--- a/doc/source/api.rst
+++ b/doc/source/api.rst
@@ -14,7 +14,7 @@ Python API
 
 .. autoclass:: Bunch
    :exclude-members: clear, copy, fromkeys, get, items, keys, pop, popitem, setdefault, update, values
-   :members: flatten
+   :members: filter, flatten
 
 Providers JSON
 --------------
diff --git a/xyzservices/_version.py b/xyzservices/_version.py
index e71ef21..f963b1f 100644
--- a/xyzservices/_version.py
+++ b/xyzservices/_version.py
@@ -51,7 +51,7 @@ class NotThisMethod(Exception):
     """Exception raised if a method is not valid for the current scenario."""
 
 
-LONG_VERSION_PY = {}
+LONG_VERSION_PY = {}  # type: ignore
 HANDLERS = {}
 
 
diff --git a/xyzservices/lib.py b/xyzservices/lib.py
index e6ebe72..1dfdc1c 100644
--- a/xyzservices/lib.py
+++ b/xyzservices/lib.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 import json
 import uuid
 import urllib.request
-from typing import Optional
+from typing import Optional, Callable, Union
 from urllib.parse import quote
 
 
@@ -121,6 +121,137 @@ class Bunch(dict):
 
         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,
+        )
+
 
 class TileProvider(Bunch):
     """
@@ -199,20 +330,20 @@ class TileProvider(Bunch):
             )
             raise AttributeError(msg)
 
-    def __call__(self, **kwargs):
+    def __call__(self, **kwargs) -> TileProvider:
         new = TileProvider(self)  # takes a copy preserving the class
         new.update(kwargs)
         return new
 
-    def copy(self, **kwargs):
+    def copy(self, **kwargs) -> TileProvider:
         new = TileProvider(self)  # takes a copy preserving the class
         return new
 
     def build_url(
         self,
-        x: Optional[int] = None,
-        y: Optional[int] = None,
-        z: Optional[int] = None,
+        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,


Test patch:
diff --git a/tests/test_providers.py b/tests/test_providers.py
index e676562..d22d3de 100644
--- a/tests/test_providers.py
+++ b/tests/test_providers.py
@@ -8,70 +8,78 @@ from xyzservices import TileProvider, Bunch
 @pytest.fixture
 def basic_provider():
     return TileProvider(
-        {
-            "url": "https://myserver.com/tiles/{z}/{x}/{y}.png",
-            "attribution": "(C) xyzservices",
-            "name": "my_public_provider",
-        }
+        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",
-        }
+        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_provider3",
-        }
+        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",
-        }
+        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>',
-            "name": "my_public_provider",
-        }
+        url="https://myserver.com/tiles/{z}/{x}/{y}.png",
+        attribution="(C) xyzservices",
+        html_attribution='&copy; <a href="https://xyzservices.readthedocs.io">xyzservices</a>',
+        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",
-        }
+        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
+        ),
     )
 
 
@@ -232,3 +240,24 @@ def test_flatten(
 
     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