Repository: ecmwf/earthkit-data
Pull request #147 (2023-07-14 11:30:21)

Feature/polytope web api
## Done
- Initial implementation of Polytope Source

## Todo
- add more test cases including grib
- documentation and examples

sandorkertesz: Looks good. You also need to update the dependencies in setup.cfg, environment.yml and tests/environment-unit-tests.yml.
sandorkertesz: Hi @TomHodson, when I tried to use the "polytope" source in a notebook for the first time I got this:

![image](https://github.com/ecmwf/earthkit-data/assets/9033020/fbebac76-df2e-4f82-940d-1f603cbe043a)

I typed in my email address then applied the generated key but the retrieval fails every time I try with this message:

```
HTTPResponseError: Polytope error
Situation: trying to list collections
Description: HTTP CLIENT ERROR (401)
URL: [https://polytope.ecmwf.int:443/api/v1/collections](https://polytope.ecmwf.int/api/v1/collections)
HTTP method: GET
Request header/body contents:
{'headers': {'Authorization': 'EmailKey **********7900, Bearer **********7900'}, 'json': None}
Expected responses: 200
Received response: CLIENT ERROR (401)
Details:
Invalid credentials
```
sandorkertesz: The retrieved data is not cached! This has to be fixed!


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d817a23..db460e9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -141,12 +141,12 @@ jobs:
         cache-env-key: ubuntu-latest-3.10
         extra-specs: |
           python=3.10
-    - name: Install package
-      run: |
-        python -m pip install --no-deps -e .
-    - name: Build documentation
-      run: |
-        make docs-build
+    # - name: Install package
+    #   run: |
+    #     python -m pip install --no-deps -e .
+    # - name: Build documentation
+    #   run: |
+    #     make docs-build
 
   integration-tests:
     needs: [unit-tests]
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 48052f9..7874606 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -15,7 +15,7 @@ repos:
   hooks:
   - id: isort
 - repo: https://github.com/psf/black
-  rev: 23.3.0
+  rev: 23.9.1
   hooks:
   - id: black
 - repo: https://github.com/keewis/blackdoc
diff --git a/docs/examples.rst b/docs/examples.rst
index d061b41..577fe3a 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -15,6 +15,8 @@ Here is a list of example notebooks to illustrate how to use earthkit-data.
     examples/cds.ipynb
     examples/ecmwf_open_data.ipynb
     examples/fdb.ipynb
+    examples/mars.ipynb
+    examples/polytope.ipynb
     examples/wekeo.ipynb
 
 
diff --git a/docs/examples/polytope.ipynb b/docs/examples/polytope.ipynb
new file mode 100644
index 0000000..0108e52
--- /dev/null
+++ b/docs/examples/polytope.ipynb
@@ -0,0 +1,188 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "efdd065e-e9fc-494d-9f73-9cb3d525dc4a",
+   "metadata": {},
+   "source": [
+    "## Retrieving data with polytope"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "75079b34-78ba-4536-8498-4dc3d0c3e646",
+   "metadata": {},
+   "source": [
+    "The “polytope” data source provides access to the [Polytope web services](https://polytope-client.readthedocs.io/en/latest/)."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "621c0aa7-db93-441a-ab55-743fa6fbcd51",
+   "metadata": {},
+   "source": [
+    "The following example retrieves data from the ECMWF MARS archive using polytope. The dataset was prepared for the OGC GeoDataCubes working group, see details [here](https://github.com/ecmwf/ogc-gdc-usecase/tree/main)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "7910ac60-a503-4392-a719-0b780625346f",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-09-29 18:03:52 - INFO - Sending request...\n",
+      "{'request': 'class: rd\\n'\n",
+      "            \"date: '20200915'\\n\"\n",
+      "            'domain: g\\n'\n",
+      "            'expver: hsvs\\n'\n",
+      "            \"levellist: '1'\\n\"\n",
+      "            'levtype: pl\\n'\n",
+      "            \"param: '129.128'\\n\"\n",
+      "            'step: 0/12\\n'\n",
+      "            'stream: oper\\n'\n",
+      "            'time: 00:00:00\\n'\n",
+      "            'type: fc\\n',\n",
+      " 'verb': 'retrieve'}\n",
+      "2023-09-29 18:03:53 - INFO - Request accepted. Please poll http://polytope.ecmwf.int/api/v1/requests/5af79420-5e06-477d-8167-a54e0de84fe1 for status\n",
+      "2023-09-29 18:03:53 - INFO - Checking request status (5af79420-5e06-477d-8167-a54e0de84fe1)...\n",
+      "2023-09-29 18:03:54 - INFO - The current status of the request is 'processing'\n",
+      "2023-09-29 18:03:58 - INFO - The current status of the request is 'processed'\n",
+      "                                                                                                                                                                   \r"
+     ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>centre</th>\n",
+       "      <th>shortName</th>\n",
+       "      <th>typeOfLevel</th>\n",
+       "      <th>level</th>\n",
+       "      <th>dataDate</th>\n",
+       "      <th>dataTime</th>\n",
+       "      <th>stepRange</th>\n",
+       "      <th>dataType</th>\n",
+       "      <th>number</th>\n",
+       "      <th>gridType</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>ecmf</td>\n",
+       "      <td>z</td>\n",
+       "      <td>isobaricInhPa</td>\n",
+       "      <td>1</td>\n",
+       "      <td>20200915</td>\n",
+       "      <td>0</td>\n",
+       "      <td>0</td>\n",
+       "      <td>fc</td>\n",
+       "      <td>0</td>\n",
+       "      <td>sh</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>ecmf</td>\n",
+       "      <td>z</td>\n",
+       "      <td>isobaricInhPa</td>\n",
+       "      <td>1</td>\n",
+       "      <td>20200915</td>\n",
+       "      <td>0</td>\n",
+       "      <td>12</td>\n",
+       "      <td>fc</td>\n",
+       "      <td>0</td>\n",
+       "      <td>sh</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "  centre shortName    typeOfLevel  level  dataDate  dataTime stepRange  \\\n",
+       "0   ecmf         z  isobaricInhPa      1  20200915         0         0   \n",
+       "1   ecmf         z  isobaricInhPa      1  20200915         0        12   \n",
+       "\n",
+       "  dataType  number gridType  \n",
+       "0       fc       0       sh  \n",
+       "1       fc       0       sh  "
+      ]
+     },
+     "execution_count": 1,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "import earthkit.data\n",
+    "\n",
+    "request = {\n",
+    "    'stream': 'oper',\n",
+    "    'levtype': 'pl',\n",
+    "    'levellist': '1',\n",
+    "    'param': '129.128',\n",
+    "    'step': '0/12',\n",
+    "    'time': '00:00:00',\n",
+    "    'date': '20200915',\n",
+    "    'type': 'fc',\n",
+    "    'class': 'rd',\n",
+    "    'expver': 'hsvs',\n",
+    "    'domain': 'g'\n",
+    "}\n",
+    "\n",
+    "\n",
+    "ds = earthkit.data.from_source(\"polytope\", \"ecmwf-mars\", request)\n",
+    "ds.ls()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "32cb0e10-7545-4758-b2f0-99984f01d71f",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "dev",
+   "language": "python",
+   "name": "dev"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.11"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/guide/sources.rst b/docs/guide/sources.rst
index 82fae65..e83c910 100644
--- a/docs/guide/sources.rst
+++ b/docs/guide/sources.rst
@@ -36,22 +36,23 @@ We can get data from a given source by using :func:`from_source`:
       - read data from a stream
     * - :ref:`data-sources-memory`
       - read data from a memory buffer
-    * - :ref:`data-sources-mars`
-      - retrieve data from the ECMWF `MARS archive <https://confluence.ecmwf.int/display/UDOC/MARS+user+documentation>`_
-    * - :ref:`data-sources-cds`
-      - retrieve data from the `Copernicus Climate Data Store <https://cds.climate.copernicus.eu/>`_ (CDS)
     * - :ref:`data-sources-ads`
       - retrieve data from the `Copernicus Atmosphere Data Store <https://ads.atmosphere.copernicus.eu/>`_ (ADS)
+    * - :ref:`data-sources-cds`
+      - retrieve data from the `Copernicus Climate Data Store <https://cds.climate.copernicus.eu/>`_ (CDS)
     * - :ref:`data-sources-eod`
       - retrieve `ECMWF open data <https://www.ecmwf.int/en/forecasts/datasets/open-data>`_
     * - :ref:`data-sources-fdb`
       - retrieve data from the `Fields DataBase <https://fields-database.readthedocs.io/en/latest/>`_ (FDB)
+    * - :ref:`data-sources-mars`
+      - retrieve data from the ECMWF `MARS archive <https://confluence.ecmwf.int/display/UDOC/MARS+user+documentation>`_
+    * - :ref:`data-sources-polytope`
+      - retrieve data from the `Polytope services <https://polytope-client.readthedocs.io/en/latest/>`_
     * - :ref:`data-sources-wekeo`
       - retrieve data from `WEkEO`_ using the WEkEO grammar
     * - :ref:`data-sources-wekeocds`
       - retrieve `CDS <https://cds.climate.copernicus.eu/>`_ data stored on `WEkEO`_ using the `cdsapi`_ grammar
 
-
 ----------------------------------
 
 .. _data-sources-file:
@@ -289,47 +290,43 @@ memory
           print(f.metadata("param"))
 
 
-.. _data-sources-mars:
-
-mars
---------------
 
-.. py:function:: from_source("mars", *args, **kwargs)
-  :noindex:
+.. _data-sources-ads:
 
-  The ``mars`` source will retrieve data from the ECMWF MARS (Meteorological Archival and Retrieval System) archive. In addition
-  to data retrieval, the request specified as ``*args`` and/or ``**kwargs`` also has GRIB post-processing options such as ``grid`` and ``area`` for regridding and
-  sub-area extraction, respectively.
+ads
+---
 
-  To figure out which data you need, or discover relevant data available in MARS, see the publicly accessible `MARS catalog`_ (or this `access restricted catalog <https://apps.ecmwf.int/mars-catalogue/>`_).
+.. py:function:: from_source("ads", dataset, *args, **kwargs)
+  :noindex:
 
-  The MARS access is direct when the MARS client is installed (as at ECMWF), otherwise it will use the `web API`_. In order to use the `web API`_ you will need to register and retrieve an access token. For a more extensive documentation about MARS, please refer to the `MARS user documentation`_.
+  The ``ads`` source accesses the `Copernicus Atmosphere Data Store`_ (ADS), using the cdsapi_ package. In addition to data retrieval, ``request`` also has post-processing options such as ``grid`` and ``area`` for regridding and sub-area extraction respectively.
 
-  :param tuple *args: positional arguments specifying the request as a dict
+  :param str dataset: the name of the ADS dataset
+  :param tuple *args: specifies the request as a dict
   :param dict **kwargs: other keyword arguments specifying the request
 
-  The following example retrieves analysis GRIB data for a subarea for 2 surface parameters:
+  The following example retrieves CAMS global reanalysis GRIB data for 2 parameters:
 
   .. code-block:: python
 
       import earthkit.data
 
       ds = earthkit.data.from_source(
-          "mars",
-          {
-              "param": ["2t", "msl"],
-              "levtype": "sfc",
-              "area": [50, -50, 20, 50],
-              "grid": [2, 2],
-              "date": "2023-05-10",
-          },
+          "ads",
+          "cams-global-reanalysis-eac4",
+          variable=["particulate_matter_10um", "particulate_matter_1um"],
+          area=[50, -50, 20, 50],  # N,W,S,E
+          date="2012-12-12",
+          time="12:00",
       )
 
-  Data downloaded from MARS is stored in the :ref:`cache <caching>`.
+  Data downloaded from the ADS is stored in the the :ref:`cache <caching>`.
+
+  To access data from the ADS, you will need to register and retrieve an access token. The process is described `here <https://ads.atmosphere.copernicus.eu/api-how-to>`__. For more information, see the `ADS_knowledge base`_.
 
   Further examples:
 
-      - :ref:`/examples/mars.ipynb`
+      - :ref:`/examples/ads.ipynb`
 
 
 .. _data-sources-cds:
@@ -372,44 +369,6 @@ cds
       - :ref:`/examples/cds.ipynb`
 
 
-.. _data-sources-ads:
-
-ads
----
-
-.. py:function:: from_source("ads", dataset, *args, **kwargs)
-  :noindex:
-
-  The ``ads`` source accesses the `Copernicus Atmosphere Data Store`_ (ADS), using the cdsapi_ package. In addition to data retrieval, ``request`` also has post-processing options such as ``grid`` and ``area`` for regridding and sub-area extraction respectively.
-
-  :param str dataset: the name of the ADS dataset
-  :param tuple *args: specifies the request as a dict
-  :param dict **kwargs: other keyword arguments specifying the request
-
-  The following example retrieves CAMS global reanalysis GRIB data for 2 parameters:
-
-  .. code-block:: python
-
-      import earthkit.data
-
-      ds = earthkit.data.from_source(
-          "ads",
-          "cams-global-reanalysis-eac4",
-          variable=["particulate_matter_10um", "particulate_matter_1um"],
-          area=[50, -50, 20, 50],  # N,W,S,E
-          date="2012-12-12",
-          time="12:00",
-      )
-
-  Data downloaded from the ADS is stored in the the :ref:`cache <caching>`.
-
-  To access data from the ADS, you will need to register and retrieve an access token. The process is described `here <https://ads.atmosphere.copernicus.eu/api-how-to>`__. For more information, see the `ADS_knowledge base`_.
-
-  Further examples:
-
-      - :ref:`/examples/ads.ipynb`
-
-
 .. _data-sources-eod:
 
 ecmwf-open-data
@@ -529,6 +488,95 @@ fdb
       - :ref:`/examples/fdb.ipynb`
 
 
+.. _data-sources-mars:
+
+mars
+--------------
+
+.. py:function:: from_source("mars", *args, **kwargs)
+  :noindex:
+
+  The ``mars`` source will retrieve data from the ECMWF MARS (Meteorological Archival and Retrieval System) archive. In addition
+  to data retrieval, the request specified as ``*args`` and/or ``**kwargs`` also has GRIB post-processing options such as ``grid`` and ``area`` for regridding and
+  sub-area extraction, respectively.
+
+  To figure out which data you need, or discover relevant data available in MARS, see the publicly accessible `MARS catalog`_ (or this `access restricted catalog <https://apps.ecmwf.int/mars-catalogue/>`_).
+
+  The MARS access is direct when the MARS client is installed (as at ECMWF), otherwise it will use the `web API`_. In order to use the `web API`_ you will need to register and retrieve an access token. For a more extensive documentation about MARS, please refer to the `MARS user documentation`_.
+
+  :param tuple *args: positional arguments specifying the request as a dict
+  :param dict **kwargs: other keyword arguments specifying the request
+
+  The following example retrieves analysis GRIB data for a subarea for 2 surface parameters:
+
+  .. code-block:: python
+
+      import earthkit.data
+
+      ds = earthkit.data.from_source(
+          "mars",
+          {
+              "param": ["2t", "msl"],
+              "levtype": "sfc",
+              "area": [50, -50, 20, 50],
+              "grid": [2, 2],
+              "date": "2023-05-10",
+          },
+      )
+
+  Data downloaded from MARS is stored in the :ref:`cache <caching>`.
+
+  Further examples:
+
+      - :ref:`/examples/mars.ipynb`
+
+
+.. _data-sources-polytope:
+
+polytope
+--------
+
+.. py:function:: from_source("polytope", collection, *args, **kwargs)
+  :noindex:
+
+  The ``polytope`` source accesses the `Polytope web services <https://polytope-client.readthedocs.io/en/latest/>`_ , using the polytope-client_ package.
+
+  :param str collection: the name of the polytope collection
+  :param tuple *args: specifies the request as a dict
+  :param dict **kwargs: other keyword arguments specifying the request
+
+  The following example retrieves GRIB data from the "ecmwf-mars" polytope collection:
+
+  .. code-block:: python
+
+      import earthkit.data
+
+      request = {
+          "stream": "oper",
+          "levtype": "pl",
+          "levellist": "1",
+          "param": "130.128",
+          "step": "0/12",
+          "time": "00:00:00",
+          "date": "20200915",
+          "type": "fc",
+          "class": "rd",
+          "expver": "hsvs",
+          "domain": "g",
+      }
+
+      ds = earthkit.data.from_source("polytope", "ecmwf-mars", request)
+
+  .. Data downloaded from the polytope service is stored in the the :ref:`cache <caching>`.
+
+  To access data from polytope, you will need to register and retrieve an access token.
+
+  Further examples:
+
+      - :ref:`/examples/polytope.ipynb`
+
+
+
 .. _data-sources-wekeo:
 
 wekeo
@@ -629,3 +677,5 @@ wekeocds
 
 .. _WEkEO: https://www.wekeo.eu/
 .. _hda: https://pypi.org/project/hda
+
+.. _polytope-client: https://pypi.org/project/polytope-client
diff --git a/earthkit/data/sources/polytope.py b/earthkit/data/sources/polytope.py
new file mode 100644
index 0000000..2a03fc1
--- /dev/null
+++ b/earthkit/data/sources/polytope.py
@@ -0,0 +1,89 @@
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import logging
+
+from . import Source
+from .multi_url import MultiUrl
+from .prompt import APIKeyPrompt
+
+LOG = logging.getLogger(__name__)
+
+
+class PolytopeWebKeyPrompt(APIKeyPrompt):
+    register_or_sign_in_url = ("",)
+    retrieve_api_key_url = ("",)
+    prompts = [
+        dict(
+            name="user_email",
+            title="Your email",
+        ),
+        dict(
+            name="user_key",
+            example="b295aad8af30332fad2fa8c963ab7900",
+            title="API key",
+            hidden=True,
+            validate="[0-9a-z]{32}",
+        ),
+    ]
+
+    rcfile = "~/.polytopeapirc"
+
+
+class Polytope(Source):
+    """
+    Retrieve data using the Polytope Web API.
+    See polytope-client.readthedocs.io for more information.
+
+    Parameters
+    ----------
+    dataset : str
+        The name of the dataset to query.
+    request: dict[str, str]
+        A collection of key : value pairs specifying the dataset.
+
+    Examples
+    --------
+    >>> src = earthkit.data.from_source("polytope", "ecmwf-mars", request)
+    >>> src.to_pandas()  # if tabular data
+    >>> src.to_xarray()  # if datacube
+    """
+
+    def __init__(self, dataset, request) -> None:
+        try:
+            import polytope
+        except ImportError:
+            raise ImportError(
+                "Polytope Web Client must be installed with 'pip install polytope-client'"
+            )
+
+        super().__init__()
+        assert isinstance(dataset, str)
+
+        self.request = dict(dataset=dataset, request=request)
+
+        credentials = PolytopeWebKeyPrompt().check(load=True)
+        self.client = polytope.api.Client(**credentials)
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self.request['dataset']}, {self.request['request']})"
+
+    def mutate(self) -> Source:
+        pointers = self.client.retrieve(
+            self.request["dataset"],
+            self.request["request"],
+            pointer=True,
+            asynchronous=False,
+        )
+
+        urls = [p["location"] for p in pointers]
+        return MultiUrl(urls)
+
+
+source = Polytope
diff --git a/environment.yml b/environment.yml
index 2637183..5e2c697 100644
--- a/environment.yml
+++ b/environment.yml
@@ -25,6 +25,7 @@ dependencies:
   - git+https://github.com/ecmwf/multiurl
   - git+https://github.com/ecmwf/pyfdb
   - ecmwf-opendata>=0.1.2
+  - polytope-client>=0.7.1
 - tqdm
 - markdown
 - make
diff --git a/setup.cfg b/setup.cfg
index 1a8ff01..15ec8d1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,6 +26,7 @@ install_requires =
     eccodes>=1.5.0
     ecmwf-api-client>=1.6.1
     ecmwf-opendata>=0.1.2
+    polytope-client>=0.7.1
     dask
     entrypoints
     filelock


Test patch:
diff --git a/earthkit/data/testing.py b/earthkit/data/testing.py
index 7dd8904..013f3ff 100644
--- a/earthkit/data/testing.py
+++ b/earthkit/data/testing.py
@@ -97,6 +97,8 @@ try:
 except Exception:
     NO_FDB = True
 
+NO_POLYTOPE = not os.path.exists(os.path.expanduser("~/.polytopeapirc"))
+
 
 def MISSING(*modules):
     return not modules_installed(*modules)
diff --git a/tests/documentation/test_notebooks.py b/tests/documentation/test_notebooks.py
index c0456d3..2b4f972 100644
--- a/tests/documentation/test_notebooks.py
+++ b/tests/documentation/test_notebooks.py
@@ -22,7 +22,14 @@ from earthkit.data.testing import MISSING, earthkit_file
 
 EXAMPLES = earthkit_file("docs", "examples")
 
-SKIP = ["fdb.ipynb", "mars.ipynb", "cds.ipynb", "ads.ipynb", "wekeo.ipynb"]
+SKIP = [
+    "fdb.ipynb",
+    "mars.ipynb",
+    "cds.ipynb",
+    "ads.ipynb",
+    "wekeo.ipynb",
+    "polytope.ipynb",
+]
 
 
 def notebooks_list():
diff --git a/tests/environment-unit-tests.yml b/tests/environment-unit-tests.yml
index 9d06954..8dde1b2 100644
--- a/tests/environment-unit-tests.yml
+++ b/tests/environment-unit-tests.yml
@@ -26,6 +26,7 @@ dependencies:
   - git+https://github.com/ecmwf/multiurl
   - git+https://github.com/ecmwf/pyfdb
   - ecmwf-opendata>=0.1.2
+  - polytope-client>=0.7.1
 - tqdm
 - markdown
 - make
diff --git a/tests/sources/test_polytope.py b/tests/sources/test_polytope.py
new file mode 100644
index 0000000..fe3b27e
--- /dev/null
+++ b/tests/sources/test_polytope.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import sys
+
+import pytest
+
+from earthkit.data import from_source
+from earthkit.data.testing import NO_POLYTOPE
+
+
+def test_no_polytope_client(monkeypatch):
+    "Check that a useful message is given in the absence of the polytope-client library"
+    monkeypatch.setitem(sys.modules, "polytope", None)
+    with pytest.raises(ImportError) as excinfo:
+        from_source("polytope", None, None)
+        assert "pip install polytope-client" in str(excinfo.value)
+
+
+@pytest.mark.long_test
+@pytest.mark.download
+@pytest.mark.skipif(NO_POLYTOPE, reason="No access to Polytope Web API")
+def test_polytope_odb():
+    request = {
+        "database": "fdbdev",
+        "class": "rd",
+        "type": "oai",
+        "stream": "lwda",
+        "expver": "xxxx",
+        "obsgroup": "CONV",
+        "reportype": 16001,
+        "obstype": 15,
+        "date": [20150601, 20150602],
+        "time": "0/to/23/by/1",
+        "filter": "'select * where stationid=\"Ams01\"'",
+        "domain": "off",
+    }
+
+    src = from_source("polytope", "ichange", request)
+    df = src.to_pandas()
+    assert len(df) == 52
+
+
+@pytest.mark.long_test
+@pytest.mark.download
+@pytest.mark.skipif(NO_POLYTOPE, reason="No access to Polytope Web API")
+def test_polytope_grib():
+    request = {
+        "stream": "oper",
+        "levtype": "pl",
+        "levellist": "500",
+        "param": "129.128",
+        "step": "0/12",
+        "time": "00:00:00",
+        "date": "20200915",
+        "type": "fc",
+        "class": "rd",
+        "expver": "hsvs",
+        "domain": "g",
+    }
+
+    ds = from_source("polytope", "ecmwf-mars", request)
+
+    assert len(ds) == 2
+    assert ds.metadata("level") == [500, 500]