summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGard Spreemann <gspr@nonempty.org>2021-08-14 20:33:19 +0200
committerGard Spreemann <gspr@nonempty.org>2021-08-14 20:33:19 +0200
commit4e608966f4cd5ea995189f8a23e00e4154ddf2a9 (patch)
tree6ee9bae4145ff00c595f3635db027c8549a84f4d
parentb70a5361202307c26bac18d15453b5193a22f11c (diff)
parentf3b94a9bc40f8d56b0d1ac8cc8bc84765509ef05 (diff)
Merge tag '0.5.1' into debian/sid
-rw-r--r--.github/workflows/check-and-publish.yml87
-rw-r--r--.gitignore9
-rw-r--r--CONTRIBUTING.rst51
-rw-r--r--LICENSE.txt177
-rw-r--r--MANIFEST.in10
-rw-r--r--README.rst62
-rw-r--r--cdsapi/__init__.py23
-rw-r--r--cdsapi/api.py640
-rw-r--r--docker/.gitignore2
-rw-r--r--docker/Dockerfile10
-rw-r--r--docker/README.md38
-rw-r--r--docker/request.json15
-rw-r--r--docker/retrieve.py12
-rwxr-xr-xexample-era5.py26
-rwxr-xr-xexample-glaciers.py19
-rwxr-xr-xexamples/example-era5-update.py51
-rw-r--r--setup.cfg17
-rw-r--r--setup.py66
-rw-r--r--tests/requirements.txt9
-rw-r--r--tests/test_api.py20
-rw-r--r--tox.ini16
21 files changed, 1360 insertions, 0 deletions
diff --git a/.github/workflows/check-and-publish.yml b/.github/workflows/check-and-publish.yml
new file mode 100644
index 0000000..10831bf
--- /dev/null
+++ b/.github/workflows/check-and-publish.yml
@@ -0,0 +1,87 @@
+name: Check and publish
+
+on:
+ push:
+ branches: [ master ]
+
+ pull_request:
+ branches: [ master ]
+
+ release:
+ types: [created]
+
+
+jobs:
+ checks:
+ strategy:
+ fail-fast: false
+ matrix:
+ platform: [windows-latest, ubuntu-latest, macos-latest]
+ python-version: ["2.7", "3.6", "3.7", "3.8", "3.9"]
+
+ name: Python ${{ matrix.python-version }} on ${{ matrix.platform }}
+ runs-on: ${{ matrix.platform }}
+
+ timeout-minutes: 20
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Tokens
+ shell: python
+ env:
+ CDSAPIRC: ${{ secrets.CDSAPIRC }}
+
+ run: |
+ from __future__ import print_function
+ import os
+ for n in ('CDSAPIRC',):
+ m = os.path.expanduser("~/." + n.lower())
+ if os.environ[n]:
+ with open(m, "w") as f:
+ print(os.environ[n], file=f)
+
+ - name: Tests
+ run: |
+ python setup.py develop
+ pip install pytest
+ pytest
+
+ deploy:
+
+ if: ${{ github.event_name == 'release' }}
+
+ name: Upload to Pypi
+ needs: checks
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.8'
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install setuptools wheel twine
+ - name: Build and publish
+ env:
+ TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+ run: |
+ python setup.py sdist
+ twine upload dist/*
+
+ - name: Notify climetlab
+ uses: mvasigh/dispatch-action@main
+ with:
+ token: ${{ secrets.NOTIFY_ECMWFLIBS }}
+ repo: climetlab
+ owner: ecmwf
+ event_type: cdsapi-updated
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2a1348b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+*.pyc
+*.data
+*.zip
+*.tgz
+*.tar
+*.tar.gz
+*.grib
+*.nc
+cdsapi.egg-info
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
new file mode 100644
index 0000000..d750f33
--- /dev/null
+++ b/CONTRIBUTING.rst
@@ -0,0 +1,51 @@
+
+.. highlight:: console
+
+How to develop
+--------------
+
+Install the package following README.rst and then install development dependencies::
+
+ $ pip install -U -r tests/requirements-dev.txt
+
+Unit tests can be run with `pytest <https://pytest.org>`_ with::
+
+ $ pytest -v --flakes --cov=cdsapi --cov-report=html --cache-clear
+
+Coverage can be checked opening in a browser the file ``htmlcov/index.html`` for example with::
+
+ $ open htmlcov/index.html
+
+Code quality control checks can be run with::
+
+ $ pytest -v --pep8 --mccabe
+
+The complete python versions tests are run via `tox <https://tox.readthedocs.io>`_ with::
+
+ $ tox
+
+Please ensure the coverage at least stays the same before you submit a pull request.
+
+
+Dependency management
+---------------------
+
+Update the `requirements-tests.txt` file with versions with::
+
+ pip-compile -U -o tests/requirements-tests.txt setup.py tests/requirements-tests.in # -U is optional
+
+
+Release procedure
+-----------------
+
+Quality check release::
+
+ $ git status
+ $ check-manifest
+ $ tox
+
+Release with zest.releaser::
+
+ $ prerelease
+ $ release
+ $ postrelease
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..f433b1a
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..e95932d
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,10 @@
+include *.in
+include *.rst
+include *.txt
+include *.py
+include LICENSE
+include tox.ini
+recursive-include cdsapi *.py
+recursive-include tests *.in
+recursive-include tests *.py
+recursive-include tests *.txt
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..166dac0
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,62 @@
+
+Install
+-------
+
+Install via `pip` with::
+
+ $ pip install cdsapi
+
+
+Configure
+---------
+
+Get your UID and API key from the CDS portal at the address https://cds.climate.copernicus.eu/user
+and write it into the configuration file, so it looks like::
+
+ $ cat ~/.cdsapirc
+ url: https://cds.climate.copernicus.eu/api/v2
+ key: <UID>:<API key>
+ verify: 0
+
+Remember to agree to the Terms and Conditions of every dataset that you intend to download.
+
+
+Test
+----
+
+Perform a small test retrieve of ERA5 data::
+
+ $ python
+ >>> import cdsapi
+ >>> cds = cdsapi.Client()
+ >>> cds.retrieve('reanalysis-era5-pressure-levels', {
+ "variable": "temperature",
+ "pressure_level": "1000",
+ "product_type": "reanalysis",
+ "date": "2017-12-01/2017-12-31",
+ "time": "12:00",
+ "format": "grib"
+ }, 'download.grib')
+ >>>
+
+
+License
+-------
+
+Copyright 2018 - 2019 European Centre for Medium-Range Weather Forecasts (ECMWF)
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+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.
diff --git a/cdsapi/__init__.py b/cdsapi/__init__.py
new file mode 100644
index 0000000..c9e9d4e
--- /dev/null
+++ b/cdsapi/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2018 European Centre for Medium-Range Weather Forecasts (ECMWF)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# 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.
+
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+from . import api
+
+Client = api.Client
diff --git a/cdsapi/api.py b/cdsapi/api.py
new file mode 100644
index 0000000..a603716
--- /dev/null
+++ b/cdsapi/api.py
@@ -0,0 +1,640 @@
+# (C) Copyright 2018 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.
+
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import json
+import time
+import os
+import logging
+import uuid
+import requests
+
+try:
+ from urllib.parse import urljoin
+except ImportError:
+ from urlparse import urljoin
+
+from tqdm import tqdm
+
+
+def bytes_to_string(n):
+ u = ["", "K", "M", "G", "T", "P"]
+ i = 0
+ while n >= 1024:
+ n /= 1024.0
+ i += 1
+ return "%g%s" % (int(n * 10 + 0.5) / 10.0, u[i])
+
+
+def read_config(path):
+ config = {}
+ with open(path) as f:
+ for l in f.readlines():
+ if ":" in l:
+ k, v = l.strip().split(":", 1)
+ if k in ("url", "key", "verify"):
+ config[k] = v.strip()
+ return config
+
+
+def toJSON(obj):
+
+ to_json = getattr(obj, "toJSON", None)
+ if callable(to_json):
+ return to_json()
+
+ if isinstance(obj, (list, tuple)):
+ return [toJSON(x) for x in obj]
+
+ if isinstance(obj, dict):
+ r = {}
+ for k, v in obj.items():
+ r[k] = toJSON(v)
+ return r
+
+ return obj
+
+
+class Result(object):
+ def __init__(self, client, reply):
+
+ self.reply = reply
+
+ self._url = client.url
+
+ self.session = client.session
+ self.robust = client.robust
+ self.verify = client.verify
+ self.cleanup = client.delete
+
+ self.debug = client.debug
+ self.info = client.info
+ self.warning = client.warning
+ self.error = client.error
+ self.sleep_max = client.sleep_max
+ self.retry_max = client.retry_max
+
+ self.timeout = client.timeout
+ self.progress = client.progress
+
+ self._deleted = False
+
+ def toJSON(self):
+ r = dict(
+ resultType="url",
+ contentType=self.content_type,
+ contentLength=self.content_length,
+ location=self.location,
+ )
+ return r
+
+ def _download(self, url, size, target):
+
+ if target is None:
+ target = url.split("/")[-1]
+
+ self.info("Downloading %s to %s (%s)", url, target, bytes_to_string(size))
+ start = time.time()
+
+ mode = "wb"
+ total = 0
+ sleep = 10
+ tries = 0
+ headers = None
+
+ while tries < self.retry_max:
+
+ r = self.robust(self.session.get)(
+ url,
+ stream=True,
+ verify=self.verify,
+ headers=headers,
+ timeout=self.timeout,
+ )
+ try:
+ r.raise_for_status()
+
+ with tqdm(
+ total=size,
+ unit_scale=True,
+ unit_divisor=1024,
+ unit="B",
+ disable=not self.progress,
+ leave=False,
+ ) as pbar:
+ pbar.update(total)
+ with open(target, mode) as f:
+ for chunk in r.iter_content(chunk_size=1024):
+ if chunk:
+ f.write(chunk)
+ total += len(chunk)
+ pbar.update(len(chunk))
+
+ except requests.exceptions.ConnectionError as e:
+ self.error("Download interupted: %s" % (e,))
+ finally:
+ r.close()
+
+ if total >= size:
+ break
+
+ self.error(
+ "Download incomplete, downloaded %s byte(s) out of %s" % (total, size)
+ )
+ self.warning("Sleeping %s seconds" % (sleep,))
+ time.sleep(sleep)
+ mode = "ab"
+ total = os.path.getsize(target)
+ sleep *= 1.5
+ if sleep > self.sleep_max:
+ sleep = self.sleep_max
+ headers = {"Range": "bytes=%d-" % total}
+ tries += 1
+ self.warning("Resuming download at byte %s" % (total,))
+
+ if total != size:
+ raise Exception(
+ "Download failed: downloaded %s byte(s) out of %s" % (total, size)
+ )
+
+ elapsed = time.time() - start
+ if elapsed:
+ self.info("Download rate %s/s", bytes_to_string(size / elapsed))
+
+ return target
+
+ def download(self, target=None):
+ return self._download(self.location, self.content_length, target)
+
+ @property
+ def content_length(self):
+ return int(self.reply["content_length"])
+
+ @property
+ def location(self):
+ return urljoin(self._url, self.reply["location"])
+
+ @property
+ def content_type(self):
+ return self.reply["content_type"]
+
+ def __repr__(self):
+ return "Result(content_length=%s,content_type=%s,location=%s)" % (
+ self.content_length,
+ self.content_type,
+ self.location,
+ )
+
+ def check(self):
+ self.debug("HEAD %s", self.location)
+ metadata = self.robust(self.session.head)(
+ self.location, verify=self.verify, timeout=self.timeout
+ )
+ metadata.raise_for_status()
+ self.debug(metadata.headers)
+ return metadata
+
+ def update(self, request_id=None):
+ if request_id is None:
+ request_id = self.reply["request_id"]
+ task_url = "%s/tasks/%s" % (self._url, request_id)
+ self.debug("GET %s", task_url)
+
+ result = self.robust(self.session.get)(task_url, verify=self.verify)
+ result.raise_for_status()
+ self.reply = result.json()
+
+ def delete(self):
+
+ if self._deleted:
+ return
+
+ if "request_id" in self.reply:
+ rid = self.reply["request_id"]
+
+ task_url = "%s/tasks/%s" % (self._url, rid)
+ self.debug("DELETE %s", task_url)
+
+ delete = self.session.delete(task_url, verify=self.verify)
+ self.debug("DELETE returns %s %s", delete.status_code, delete.reason)
+
+ try:
+ delete.raise_for_status()
+ except Exception:
+ self.warning(
+ "DELETE %s returns %s %s",
+ task_url,
+ delete.status_code,
+ delete.reason,
+ )
+
+ self._deleted = True
+
+ def __del__(self):
+ try:
+ if self.cleanup:
+ self.delete()
+ except Exception as e:
+ print(e)
+
+
+class Client(object):
+
+ logger = logging.getLogger("cdsapi")
+
+ def __init__(
+ self,
+ url=os.environ.get("CDSAPI_URL"),
+ key=os.environ.get("CDSAPI_KEY"),
+ quiet=False,
+ debug=False,
+ verify=None,
+ timeout=60,
+ progress=True,
+ full_stack=False,
+ delete=True,
+ retry_max=500,
+ sleep_max=120,
+ wait_until_complete=True,
+ info_callback=None,
+ warning_callback=None,
+ error_callback=None,
+ debug_callback=None,
+ metadata=None,
+ forget=False,
+ session=requests.Session(),
+ ):
+
+ if not quiet:
+
+ if debug:
+ level = logging.DEBUG
+ else:
+ level = logging.INFO
+
+ logging.basicConfig(
+ level=level, format="%(asctime)s %(levelname)s %(message)s"
+ )
+
+ dotrc = os.environ.get("CDSAPI_RC", os.path.expanduser("~/.cdsapirc"))
+
+ if url is None or key is None:
+ if os.path.exists(dotrc):
+ config = read_config(dotrc)
+
+ if key is None:
+ key = config.get("key")
+
+ if url is None:
+ url = config.get("url")
+
+ if verify is None:
+ verify = int(config.get("verify", 1))
+
+ if url is None or key is None or key is None:
+ raise Exception("Missing/incomplete configuration file: %s" % (dotrc))
+
+ self.url = url
+ self.key = key
+
+ self.quiet = quiet
+ self.progress = progress and not quiet
+
+ self.verify = True if verify else False
+ self.timeout = timeout
+ self.sleep_max = sleep_max
+ self.retry_max = retry_max
+ self.full_stack = full_stack
+ self.delete = delete
+ self.last_state = None
+ self.wait_until_complete = wait_until_complete
+
+ self.debug_callback = debug_callback
+ self.warning_callback = warning_callback
+ self.info_callback = info_callback
+ self.error_callback = error_callback
+
+ self.session = session
+ self.session.auth = tuple(self.key.split(":", 2))
+
+ self.metadata = metadata
+ self.forget = forget
+
+ self.debug(
+ "CDSAPI %s",
+ dict(
+ url=self.url,
+ key=self.key,
+ quiet=self.quiet,
+ verify=self.verify,
+ timeout=self.timeout,
+ progress=self.progress,
+ sleep_max=self.sleep_max,
+ retry_max=self.retry_max,
+ full_stack=self.full_stack,
+ delete=self.delete,
+ metadata=self.metadata,
+ forget=self.forget,
+ ),
+ )
+
+ def retrieve(self, name, request, target=None):
+ result = self._api("%s/resources/%s" % (self.url, name), request, "POST")
+ if target is not None:
+ result.download(target)
+ return result
+
+ def service(self, name, *args, **kwargs):
+ self.delete = False # Don't delete results
+ name = "/".join(name.split("."))
+ mimic_ui = kwargs.pop('mimic_ui', False)
+ # To mimic the CDS ui the request should be populated directly with the kwargs
+ if mimic_ui:
+ request = kwargs
+ else:
+ request = dict(args=args, kwargs=kwargs)
+
+ if self.metadata:
+ request["_cds_metadata"] = self.metadata
+ request = toJSON(request)
+ result = self._api(
+ "%s/tasks/services/%s/clientid-%s" % (self.url, name, uuid.uuid4().hex),
+ request,
+ "PUT",
+ )
+ return result
+
+ def workflow(self, code, *args, **kwargs):
+ workflow_name = kwargs.pop("workflow_name", "application")
+ params = dict(code=code, args=args, kwargs=kwargs, workflow_name=workflow_name)
+ return self.service("tool.toolbox.orchestrator.run_workflow", params)
+
+ def status(self, context=None):
+ url = "%s/status.json" % (self.url,)
+ r = self.session.get(url, verify=self.verify)
+ r.raise_for_status()
+ return r.json()
+
+ def _status(self, url):
+ try:
+ status = self.status(url)
+
+ info = status.get("info", [])
+ if not isinstance(info, list):
+ info = [info]
+ for i in info:
+ self.info("%s", i)
+
+ warning = status.get("warning", [])
+ if not isinstance(warning, list):
+ warning = [warning]
+ for w in warning:
+ self.warning("%s", w)
+
+ except Exception:
+ pass
+
+ def _api(self, url, request, method):
+
+ self._status(url)
+
+ session = self.session
+
+ self.info("Sending request to %s", url)
+ self.debug("%s %s %s", method, url, json.dumps(request))
+
+ if method == "PUT":
+ action = session.put
+ else:
+ action = session.post
+
+ result = self.robust(action)(
+ url, json=request, verify=self.verify, timeout=self.timeout
+ )
+
+ if self.forget:
+ return result
+
+ reply = None
+
+ try:
+ result.raise_for_status()
+ reply = result.json()
+ except Exception:
+
+ if reply is None:
+ try:
+ reply = result.json()
+ except Exception:
+ reply = dict(message=result.text)
+
+ self.debug(json.dumps(reply))
+
+ if "message" in reply:
+ error = reply["message"]
+
+ if "context" in reply and "required_terms" in reply["context"]:
+ e = [error]
+ for t in reply["context"]["required_terms"]:
+ e.append(
+ "To access this resource, you first need to accept the terms"
+ "of '%s' at %s" % (t["title"], t["url"])
+ )
+ error = ". ".join(e)
+ raise Exception(error)
+ else:
+ raise
+
+ if not self.wait_until_complete:
+ return Result(self, reply)
+
+ sleep = 1
+
+ while True:
+
+ self.debug("REPLY %s", reply)
+
+ if reply["state"] != self.last_state:
+ self.info("Request is %s" % (reply["state"],))
+ self.last_state = reply["state"]
+
+ if reply["state"] == "completed":
+ self.debug("Done")
+
+ if "result" in reply:
+ return reply["result"]
+
+ return Result(self, reply)
+
+ if reply["state"] in ("queued", "running"):
+ rid = reply["request_id"]
+
+ self.debug("Request ID is %s, sleep %s", rid, sleep)
+ time.sleep(sleep)
+ sleep *= 1.5
+ if sleep > self.sleep_max:
+ sleep = self.sleep_max
+
+ task_url = "%s/tasks/%s" % (self.url, rid)
+ self.debug("GET %s", task_url)
+
+ result = self.robust(session.get)(
+ task_url, verify=self.verify, timeout=self.timeout
+ )
+ result.raise_for_status()
+ reply = result.json()
+ continue
+
+ if reply["state"] in ("failed",):
+ self.error("Message: %s", reply["error"].get("message"))
+ self.error("Reason: %s", reply["error"].get("reason"))
+ for n in (
+ reply.get("error", {})
+ .get("context", {})
+ .get("traceback", "")
+ .split("\n")
+ ):
+ if n.strip() == "" and not self.full_stack:
+ break
+ self.error(" %s", n)
+ raise Exception(
+ "%s. %s."
+ % (reply["error"].get("message"), reply["error"].get("reason"))
+ )
+
+ raise Exception("Unknown API state [%s]" % (reply["state"],))
+
+ def info(self, *args, **kwargs):
+ if self.info_callback:
+ self.info_callback(*args, **kwargs)
+ else:
+ self.logger.info(*args, **kwargs)
+
+ def warning(self, *args, **kwargs):
+ if self.warning_callback:
+ self.warning_callback(*args, **kwargs)
+ else:
+ self.logger.warning(*args, **kwargs)
+
+ def error(self, *args, **kwargs):
+ if self.error_callback:
+ self.error_callback(*args, **kwargs)
+ else:
+ self.logger.error(*args, **kwargs)
+
+ def debug(self, *args, **kwargs):
+ if self.debug_callback:
+ self.debug_callback(*args, **kwargs)
+ else:
+ self.logger.debug(*args, **kwargs)
+
+ def _download(self, results, targets=None):
+
+ if isinstance(results, Result):
+ if targets:
+ path = targets.pop(0)
+ else:
+ path = None
+ return results.download(path)
+
+ if isinstance(results, (list, tuple)):
+ return [self._download(x, targets) for x in results]
+
+ if isinstance(results, dict):
+
+ if "location" in results and "contentLength" in results:
+ reply = dict(
+ location=results["location"],
+ content_length=results["contentLength"],
+ content_type=results.get("contentType"),
+ )
+
+ if targets:
+ path = targets.pop(0)
+ else:
+ path = None
+
+ return Result(self, reply).download(path)
+
+ r = {}
+ for k, v in results.items():
+ r[v] = self._download(v, targets)
+ return r
+
+ return results
+
+ def download(self, results, targets=None):
+ if targets:
+ # Make a copy
+ targets = [t for t in targets]
+ return self._download(results, targets)
+
+ def remote(self, url):
+ r = requests.head(url)
+ reply = dict(
+ location=url,
+ content_length=r.headers["Content-Length"],
+ content_type=r.headers["Content-Type"],
+ )
+ return Result(self, reply)
+
+ def robust(self, call):
+ def retriable(code, reason):
+
+ if code in [
+ requests.codes.internal_server_error,
+ requests.codes.bad_gateway,
+ requests.codes.service_unavailable,
+ requests.codes.gateway_timeout,
+ requests.codes.too_many_requests,
+ requests.codes.request_timeout,
+ ]:
+ return True
+
+ return False
+
+ def wrapped(*args, **kwargs):
+ tries = 0
+ while tries < self.retry_max:
+ try:
+ r = call(*args, **kwargs)
+ except (
+ requests.exceptions.ConnectionError,
+ requests.exceptions.ReadTimeout,
+ ) as e:
+ r = None
+ self.warning(
+ "Recovering from connection error [%s], attemps %s of %s",
+ e,
+ tries,
+ self.retry_max,
+ )
+
+ if r is not None:
+ if not retriable(r.status_code, r.reason):
+ return r
+ try:
+ self.warning(r.json()["reason"])
+ except Exception:
+ pass
+ self.warning(
+ "Recovering from HTTP error [%s %s], attemps %s of %s",
+ r.status_code,
+ r.reason,
+ tries,
+ self.retry_max,
+ )
+
+ tries += 1
+
+ self.warning("Retrying in %s seconds", self.sleep_max)
+ time.sleep(self.sleep_max)
+ self.info("Retrying now...")
+
+ return wrapped
diff --git a/docker/.gitignore b/docker/.gitignore
new file mode 100644
index 0000000..5814586
--- /dev/null
+++ b/docker/.gitignore
@@ -0,0 +1,2 @@
+output/*
+
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000..b92e74c
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.7-alpine
+
+RUN pip3 install cdsapi
+WORKDIR /input
+COPY request.json request.json
+WORKDIR /output
+WORKDIR /app
+COPY retrieve.py retrieve.py
+
+CMD ["python", "retrieve.py"]
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000..a6555aa
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,38 @@
+## Simple wrapper around cdsapi
+
+cdsapi homepage : https://github.com/ecmwf/cdsapi
+
+### How to use the dockerized version ?
+
+1. Write a request in json file – don't forget the file format and name. Eg.
+
+```js
+{
+ "url": "https://cds.climate.copernicus.eu/api/v2",
+ "uuid": "<user id>",
+ "key": "<user key>",
+ "variable": "reanalysis-era5-pressure-levels",
+ "options": {
+ "variable": "temperature",
+ "pressure_level": "1000",
+ "product_type": "reanalysis",
+ "date": "2017-12-01/2017-12-31",
+ "time": "12:00",
+ "format": "grib"
+ },
+ "filename":"test.grib"
+}
+```
+
+2. Run the command
+
+```sh
+docker run -it --rm \
+ -v $(pwd)/request.json:/input/request.json \
+ -v $(pwd)/.:/output \
+ <YOUR REPO>/cdsretrieve
+```
+
+Note : the file will be downloaded in the current folder, if not specified otherwise in the docker command. Inside the container, `/input` folder include the request and `/output` is target folder for the downloaded file.
+
+
diff --git a/docker/request.json b/docker/request.json
new file mode 100644
index 0000000..251210c
--- /dev/null
+++ b/docker/request.json
@@ -0,0 +1,15 @@
+{
+ "url": "https://cds.climate.copernicus.eu/api/v2",
+ "uuid": "< YOUR USER ID >",
+ "key": "< YOUR API KEY >",
+ "variable": "reanalysis-era5-pressure-levels",
+ "options": {
+ "variable": "temperature",
+ "pressure_level": "1000",
+ "product_type": "reanalysis",
+ "date": "2017-12-01/2017-12-31",
+ "time": "12:00",
+ "format": "grib"
+ },
+ "filename":"test.grib"
+}
diff --git a/docker/retrieve.py b/docker/retrieve.py
new file mode 100644
index 0000000..01ba3f5
--- /dev/null
+++ b/docker/retrieve.py
@@ -0,0 +1,12 @@
+import json, sys, cdsapi
+
+with open("/input/request.json") as req:
+ request = json.load(req)
+
+cds = cdsapi.Client(request.get("url"), request.get("uuid") + ":" + request.get("key"))
+
+cds.retrieve(
+ request.get("variable"),
+ request.get("options"),
+ "/output/" + request.get("filename"),
+)
diff --git a/example-era5.py b/example-era5.py
new file mode 100755
index 0000000..db42268
--- /dev/null
+++ b/example-era5.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python
+
+# (C) Copyright 2018 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 cdsapi
+
+c = cdsapi.Client()
+
+r = c.retrieve(
+ "reanalysis-era5-single-levels",
+ {
+ "variable": "2t",
+ "product_type": "reanalysis",
+ "date": "2012-12-01",
+ "time": "14:00",
+ "format": "netcdf",
+ },
+)
+
+r.download("test.nc")
diff --git a/example-glaciers.py b/example-glaciers.py
new file mode 100755
index 0000000..538f9f6
--- /dev/null
+++ b/example-glaciers.py
@@ -0,0 +1,19 @@
+#!/usr/bin/env python
+
+# (C) Copyright 2018 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 cdsapi
+
+c = cdsapi.Client()
+
+c.retrieve(
+ "insitu-glaciers-elevation-mass",
+ {"variable": "elevation_change", "format": "tgz"},
+ "dowload.data",
+)
diff --git a/examples/example-era5-update.py b/examples/example-era5-update.py
new file mode 100755
index 0000000..8b56573
--- /dev/null
+++ b/examples/example-era5-update.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python
+
+# (C) Copyright 2018 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 time
+import cdsapi
+
+c = cdsapi.Client(debug=True, wait_until_complete=False)
+
+r = c.retrieve(
+ "reanalysis-era5-single-levels",
+ {
+ "variable": "2t",
+ "product_type": "reanalysis",
+ "date": "2015-12-01",
+ "time": "14:00",
+ "format": "netcdf",
+ },
+)
+
+sleep = 30
+while True:
+ r.update()
+ reply = r.reply
+ r.info("Request ID: %s, state: %s" % (reply["request_id"], reply["state"]))
+
+ if reply["state"] == "completed":
+ break
+ elif reply["state"] in ("queued", "running"):
+ r.info("Request ID: %s, sleep: %s", reply["request_id"], sleep)
+ time.sleep(sleep)
+ elif reply["state"] in ("failed",):
+ r.error("Message: %s", reply["error"].get("message"))
+ r.error("Reason: %s", reply["error"].get("reason"))
+ for n in (
+ reply.get("error", {}).get("context", {}).get("traceback", "").split("\n")
+ ):
+ if n.strip() == "":
+ break
+ r.error(" %s", n)
+ raise Exception(
+ "%s. %s." % (reply["error"].get("message"), reply["error"].get("reason"))
+ )
+
+r.download("test.nc")
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..64fb455
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,17 @@
+[bdist_wheel]
+universal = 1
+
+[aliases]
+test = pytest
+
+[tool:pytest]
+norecursedirs =
+ build
+ dist
+ .tox
+ .eggs
+pep8maxlinelength = 109
+mccabe-complexity = 10
+
+[coverage:run]
+branch = True
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..706ac21
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+
+# Copyright 2018 European Centre for Medium-Range Weather Forecasts (ECMWF)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# 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 io
+import os.path
+
+import setuptools
+
+
+def read(fname):
+ file_path = os.path.join(os.path.dirname(__file__), fname)
+ return io.open(file_path, encoding="utf-8").read()
+
+
+version = "0.5.1"
+
+
+setuptools.setup(
+ name="cdsapi",
+ version=version,
+ author="ECMWF",
+ author_email="software.support@ecmwf.int",
+ license="Apache 2.0",
+ url="https://github.com/ecmwf/cdsapi",
+ description="Climate Data Store API",
+ long_description=read("README.rst"),
+ packages=setuptools.find_packages(),
+ include_package_data=True,
+ install_requires=[
+ "requests>=2.5.0",
+ "tqdm",
+ ],
+ zip_safe=True,
+ classifiers=[
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 2",
+ "Programming Language :: Python :: 2.7",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.4",
+ "Programming Language :: Python :: 3.5",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: Implementation :: CPython",
+ "Programming Language :: Python :: Implementation :: PyPy",
+ "Operating System :: OS Independent",
+ ],
+)
diff --git a/tests/requirements.txt b/tests/requirements.txt
new file mode 100644
index 0000000..ad28cc6
--- /dev/null
+++ b/tests/requirements.txt
@@ -0,0 +1,9 @@
+#
+pytest-cov
+pytest-env
+pytest-flakes
+pytest-mccabe
+pytest-pep8
+pytest-runner
+pytest
+requests
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..c3f3766
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,20 @@
+import cdsapi
+import os
+
+def test_request():
+
+ c = cdsapi.Client()
+
+ r = c.retrieve(
+ "reanalysis-era5-single-levels",
+ {
+ "variable": "2t",
+ "product_type": "reanalysis",
+ "date": "2012-12-01",
+ "time": "12:00"
+ },
+ )
+
+ r.download("test.grib")
+
+ assert os.path.getsize("test.grib") == 2076600
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..1d50849
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,16 @@
+[tox]
+envlist = qc, py36, py35, py34, py27, pypy3, pypy, deps
+
+[testenv]
+setenv = PYTHONPATH = {toxinidir}
+deps = -r{toxinidir}/tests/requirements-tests.txt
+commands = pytest -v --flakes --cache-clear --basetemp={envtmpdir} {posargs}
+
+[testenv:qc]
+# needed for pytest-cov
+usedevelop = true
+commands = pytest -v --pep8 --mccabe --cov=cdsapi --cov-report=html --cache-clear {posargs}
+
+[testenv:deps]
+deps =
+commands = python setup.py test