├── .github └── workflows │ ├── check-and-publish.yml │ └── label-public-pr.yml ├── .gitignore ├── CONTRIBUTING.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── cdsapi ├── __init__.py └── api.py ├── docker ├── .gitignore ├── Dockerfile ├── README.md ├── request.json └── retrieve.py ├── example-era5.py ├── example-glaciers.py ├── examples └── example-era5-update.py ├── setup.cfg ├── setup.py ├── tests ├── requirements.txt └── test_api.py └── tox.ini /.github/workflows/check-and-publish.yml: -------------------------------------------------------------------------------- 1 | name: Check and publish 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | pull_request: 8 | branches: [master] 9 | 10 | # Trigger on public pull request approval 11 | pull_request_target: 12 | types: [labeled] 13 | 14 | release: 15 | types: [created] 16 | 17 | jobs: 18 | quality-checks: 19 | name: Code QA 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - run: pip install black flake8 isort 24 | - run: black --version 25 | - run: isort --version 26 | - run: flake8 --version 27 | - run: isort --check . 28 | - run: black --check . 29 | - run: flake8 . 30 | 31 | platform-checks: 32 | needs: quality-checks 33 | if: ${{ !github.event.pull_request.head.repo.fork && github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci' }} 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | platform: [windows-latest, ubuntu-latest, macos-latest] 38 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 39 | exclude: 40 | - platform: macos-latest 41 | python-version: "3.8" 42 | - platform: macos-latest 43 | python-version: "3.9" 44 | 45 | name: Python ${{ matrix.python-version }} on ${{ matrix.platform }} 46 | runs-on: ${{ matrix.platform }} 47 | 48 | timeout-minutes: 20 49 | 50 | steps: 51 | - uses: actions/checkout@v3 52 | with: 53 | ref: ${{ github.event.pull_request.head.sha || github.ref }} 54 | 55 | - uses: actions/setup-python@v2 56 | with: 57 | python-version: ${{ matrix.python-version }} 58 | 59 | - name: Tests 60 | env: 61 | CDSAPI_URL: https://cds.climate.copernicus.eu/api 62 | CDSAPI_KEY: ${{ secrets.CDSAPI_KEY_2024 }} 63 | run: | 64 | pip install setuptools 65 | python setup.py develop 66 | pip install pytest 67 | pytest 68 | 69 | deploy: 70 | needs: platform-checks 71 | 72 | if: ${{ github.event_name == 'release' }} 73 | 74 | name: Upload to Pypi 75 | 76 | runs-on: ubuntu-latest 77 | 78 | steps: 79 | - uses: actions/checkout@v3 80 | with: 81 | ref: ${{ github.event.pull_request.head.sha || github.ref }} 82 | - name: Build distributions 83 | run: | 84 | $CONDA/bin/python -m pip install build 85 | $CONDA/bin/python -m build 86 | - name: Publish a Python distribution to PyPI 87 | uses: pypa/gh-action-pypi-publish@release/v1 88 | with: 89 | user: __token__ 90 | password: ${{ secrets.PYPI_API_TOKEN }} 91 | 92 | - name: Notify climetlab 93 | uses: mvasigh/dispatch-action@main 94 | with: 95 | token: ${{ secrets.NOTIFY_ECMWFLIBS }} 96 | repo: climetlab 97 | owner: ecmwf 98 | event_type: cdsapi-updated 99 | -------------------------------------------------------------------------------- /.github/workflows/label-public-pr.yml: -------------------------------------------------------------------------------- 1 | # Manage labels of pull requests that originate from forks 2 | name: label-public-pr 3 | 4 | on: 5 | pull_request_target: 6 | types: [opened, synchronize] 7 | 8 | jobs: 9 | label: 10 | uses: ecmwf-actions/reusable-workflows/.github/workflows/label-pr.yml@v2 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.data 3 | *.zip 4 | *.tgz 5 | *.tar 6 | *.tar.gz 7 | *.grib 8 | *.nc 9 | cdsapi.egg-info 10 | build/ 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | 2 | .. highlight:: console 3 | 4 | How to develop 5 | -------------- 6 | 7 | Install the package following README.rst and then install development dependencies:: 8 | 9 | $ pip install -U -r tests/requirements-dev.txt 10 | 11 | Unit tests can be run with `pytest `_ with:: 12 | 13 | $ pytest -v --flakes --cov=cdsapi --cov-report=html --cache-clear 14 | 15 | Coverage can be checked opening in a browser the file ``htmlcov/index.html`` for example with:: 16 | 17 | $ open htmlcov/index.html 18 | 19 | Code quality control checks can be run with:: 20 | 21 | $ pytest -v --pep8 --mccabe 22 | 23 | The complete python versions tests are run via `tox `_ with:: 24 | 25 | $ tox 26 | 27 | Please ensure the coverage at least stays the same before you submit a pull request. 28 | 29 | 30 | Dependency management 31 | --------------------- 32 | 33 | Update the `requirements-tests.txt` file with versions with:: 34 | 35 | pip-compile -U -o tests/requirements-tests.txt setup.py tests/requirements-tests.in # -U is optional 36 | 37 | 38 | Release procedure 39 | ----------------- 40 | 41 | Quality check release:: 42 | 43 | $ git status 44 | $ check-manifest 45 | $ tox 46 | 47 | Release with zest.releaser:: 48 | 49 | $ prerelease 50 | $ release 51 | $ postrelease 52 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.in 2 | include *.rst 3 | include *.txt 4 | include *.py 5 | include LICENSE 6 | include tox.ini 7 | recursive-include cdsapi *.py 8 | recursive-include tests *.in 9 | recursive-include tests *.py 10 | recursive-include tests *.txt 11 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | cdsapi 2 | ------ 3 | 4 | For a more detailed description on how to use the cdsapi, please visit: https://cds.climate.copernicus.eu/how-to-api 5 | 6 | 7 | Install 8 | ------- 9 | 10 | Install via `pip` with:: 11 | 12 | $ pip install cdsapi 13 | 14 | 15 | Configure 16 | --------- 17 | 18 | Get your Personal Access Token from your profile on the CDS portal at the address: https://cds.climate.copernicus.eu/profile 19 | and write it into the configuration file, so it looks like:: 20 | 21 | $ cat ~/.cdsapirc 22 | url: https://cds.climate.copernicus.eu/api 23 | key: 24 | 25 | Remember to agree to the Terms and Conditions of every dataset that you intend to download. 26 | 27 | 28 | Test 29 | ---- 30 | 31 | Perform a small test retrieve of ERA5 data:: 32 | 33 | $ python 34 | >>> import cdsapi 35 | >>> cds = cdsapi.Client() 36 | >>> cds.retrieve('reanalysis-era5-pressure-levels', { 37 | "variable": "temperature", 38 | "pressure_level": "1000", 39 | "product_type": "reanalysis", 40 | "date": "2017-12-01/2017-12-31", 41 | "time": "12:00", 42 | "format": "grib" 43 | }, 'download.grib') 44 | >>> 45 | 46 | 47 | License 48 | ------- 49 | 50 | Copyright 2018 - 2019 European Centre for Medium-Range Weather Forecasts (ECMWF) 51 | 52 | Licensed under the Apache License, Version 2.0 (the "License"); 53 | you may not use this file except in compliance with the License. 54 | You may obtain a copy of the License at 55 | 56 | http://www.apache.org/licenses/LICENSE-2.0 57 | 58 | Unless required by applicable law or agreed to in writing, software 59 | distributed under the License is distributed on an "AS IS" BASIS, 60 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 61 | See the License for the specific language governing permissions and 62 | limitations under the License. 63 | 64 | In applying this licence, ECMWF does not waive the privileges and immunities 65 | granted to it by virtue of its status as an intergovernmental organisation nor 66 | does it submit to any jurisdiction. 67 | -------------------------------------------------------------------------------- /cdsapi/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 European Centre for Medium-Range Weather Forecasts (ECMWF) 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # In applying this licence, ECMWF does not waive the privileges and immunities 16 | # granted to it by virtue of its status as an intergovernmental organisation nor 17 | # does it submit to any jurisdiction. 18 | 19 | from __future__ import absolute_import, division, print_function, unicode_literals 20 | 21 | from . import api 22 | 23 | Client = api.Client 24 | -------------------------------------------------------------------------------- /cdsapi/api.py: -------------------------------------------------------------------------------- 1 | # (C) Copyright 2018 ECMWF. 2 | # 3 | # This software is licensed under the terms of the Apache Licence Version 2.0 4 | # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. 5 | # In applying this licence, ECMWF does not waive the privileges and immunities 6 | # granted to it by virtue of its status as an intergovernmental organisation nor 7 | # does it submit to any jurisdiction. 8 | 9 | from __future__ import absolute_import, division, print_function, unicode_literals 10 | 11 | import json 12 | import logging 13 | import os 14 | import time 15 | import uuid 16 | from importlib.metadata import version 17 | 18 | import requests 19 | 20 | try: 21 | from urllib.parse import urljoin 22 | except ImportError: 23 | from urlparse import urljoin 24 | 25 | from tqdm import tqdm 26 | 27 | 28 | def bytes_to_string(n): 29 | u = ["", "K", "M", "G", "T", "P"] 30 | i = 0 31 | while n >= 1024: 32 | n /= 1024.0 33 | i += 1 34 | return "%g%s" % (int(n * 10 + 0.5) / 10.0, u[i]) 35 | 36 | 37 | def read_config(path): 38 | config = {} 39 | with open(path) as f: 40 | for line in f.readlines(): 41 | if ":" in line: 42 | k, v = line.strip().split(":", 1) 43 | if k in ("url", "key", "verify"): 44 | config[k] = v.strip() 45 | return config 46 | 47 | 48 | def get_url_key_verify(url, key, verify): 49 | if url is None: 50 | url = os.environ.get("CDSAPI_URL") 51 | if key is None: 52 | key = os.environ.get("CDSAPI_KEY") 53 | dotrc = os.environ.get("CDSAPI_RC", os.path.expanduser("~/.cdsapirc")) 54 | 55 | if url is None or key is None: 56 | if os.path.exists(dotrc): 57 | config = read_config(dotrc) 58 | 59 | if key is None: 60 | key = config.get("key") 61 | 62 | if url is None: 63 | url = config.get("url") 64 | 65 | if verify is None: 66 | verify = bool(int(config.get("verify", 1))) 67 | 68 | if url is None or key is None: 69 | raise Exception("Missing/incomplete configuration file: %s" % (dotrc)) 70 | 71 | # If verify is still None, then we set to default value of True 72 | if verify is None: 73 | verify = True 74 | return url, key, verify 75 | 76 | 77 | def toJSON(obj): 78 | to_json = getattr(obj, "toJSON", None) 79 | if callable(to_json): 80 | return to_json() 81 | 82 | if isinstance(obj, (list, tuple)): 83 | return [toJSON(x) for x in obj] 84 | 85 | if isinstance(obj, dict): 86 | r = {} 87 | for k, v in obj.items(): 88 | r[k] = toJSON(v) 89 | return r 90 | 91 | return obj 92 | 93 | 94 | class Result(object): 95 | def __init__(self, client, reply): 96 | self.reply = reply 97 | 98 | self._url = client.url 99 | 100 | self.session = client.session 101 | self.robust = client.robust 102 | self.verify = client.verify 103 | self.cleanup = client.delete 104 | 105 | self.debug = client.debug 106 | self.info = client.info 107 | self.warning = client.warning 108 | self.error = client.error 109 | self.sleep_max = client.sleep_max 110 | self.retry_max = client.retry_max 111 | 112 | self.timeout = client.timeout 113 | self.progress = client.progress 114 | 115 | self._deleted = False 116 | 117 | def toJSON(self): 118 | r = dict( 119 | resultType="url", 120 | contentType=self.content_type, 121 | contentLength=self.content_length, 122 | location=self.location, 123 | ) 124 | return r 125 | 126 | def _download(self, url, size, target): 127 | if target is None: 128 | target = url.split("/")[-1] 129 | 130 | self.info("Downloading %s to %s (%s)", url, target, bytes_to_string(size)) 131 | start = time.time() 132 | 133 | mode = "wb" 134 | total = 0 135 | sleep = 10 136 | tries = 0 137 | headers = None 138 | 139 | while tries < self.retry_max: 140 | r = self.robust(self.session.get)( 141 | url, 142 | stream=True, 143 | verify=self.verify, 144 | headers=headers, 145 | timeout=self.timeout, 146 | ) 147 | try: 148 | r.raise_for_status() 149 | 150 | with tqdm( 151 | total=size, 152 | unit_scale=True, 153 | unit_divisor=1024, 154 | unit="B", 155 | disable=not self.progress, 156 | leave=False, 157 | ) as pbar: 158 | pbar.update(total) 159 | with open(target, mode) as f: 160 | for chunk in r.iter_content(chunk_size=1024): 161 | if chunk: 162 | f.write(chunk) 163 | total += len(chunk) 164 | pbar.update(len(chunk)) 165 | 166 | except requests.exceptions.ConnectionError as e: 167 | self.error("Download interupted: %s" % (e,)) 168 | finally: 169 | r.close() 170 | 171 | if total >= size: 172 | break 173 | 174 | self.error( 175 | "Download incomplete, downloaded %s byte(s) out of %s" % (total, size) 176 | ) 177 | self.warning("Sleeping %s seconds" % (sleep,)) 178 | time.sleep(sleep) 179 | mode = "ab" 180 | total = os.path.getsize(target) 181 | sleep *= 1.5 182 | if sleep > self.sleep_max: 183 | sleep = self.sleep_max 184 | headers = {"Range": "bytes=%d-" % total} 185 | tries += 1 186 | self.warning("Resuming download at byte %s" % (total,)) 187 | 188 | if total != size: 189 | raise Exception( 190 | "Download failed: downloaded %s byte(s) out of %s" % (total, size) 191 | ) 192 | 193 | elapsed = time.time() - start 194 | if elapsed: 195 | self.info("Download rate %s/s", bytes_to_string(size / elapsed)) 196 | 197 | return target 198 | 199 | def download(self, target=None): 200 | return self._download(self.location, self.content_length, target) 201 | 202 | @property 203 | def content_length(self): 204 | return int(self.reply["content_length"]) 205 | 206 | @property 207 | def location(self): 208 | return urljoin(self._url, self.reply["location"]) 209 | 210 | @property 211 | def content_type(self): 212 | return self.reply["content_type"] 213 | 214 | def __repr__(self): 215 | return "Result(content_length=%s,content_type=%s,location=%s)" % ( 216 | self.content_length, 217 | self.content_type, 218 | self.location, 219 | ) 220 | 221 | def check(self): 222 | self.debug("HEAD %s", self.location) 223 | metadata = self.robust(self.session.head)( 224 | self.location, verify=self.verify, timeout=self.timeout 225 | ) 226 | metadata.raise_for_status() 227 | self.debug(metadata.headers) 228 | return metadata 229 | 230 | def update(self, request_id=None): 231 | if request_id is None: 232 | request_id = self.reply["request_id"] 233 | task_url = "%s/tasks/%s" % (self._url, request_id) 234 | self.debug("GET %s", task_url) 235 | 236 | result = self.robust(self.session.get)( 237 | task_url, verify=self.verify, timeout=self.timeout 238 | ) 239 | result.raise_for_status() 240 | self.reply = result.json() 241 | 242 | def delete(self): 243 | if self._deleted: 244 | return 245 | 246 | if "request_id" in self.reply: 247 | rid = self.reply["request_id"] 248 | 249 | task_url = "%s/tasks/%s" % (self._url, rid) 250 | self.debug("DELETE %s", task_url) 251 | 252 | delete = self.session.delete( 253 | task_url, verify=self.verify, timeout=self.timeout 254 | ) 255 | self.debug("DELETE returns %s %s", delete.status_code, delete.reason) 256 | 257 | try: 258 | delete.raise_for_status() 259 | except Exception: 260 | self.warning( 261 | "DELETE %s returns %s %s", 262 | task_url, 263 | delete.status_code, 264 | delete.reason, 265 | ) 266 | 267 | self._deleted = True 268 | 269 | def __del__(self): 270 | try: 271 | if self.cleanup: 272 | self.delete() 273 | except Exception as e: 274 | print(e) 275 | 276 | 277 | class Client(object): 278 | logger = logging.getLogger("cdsapi") 279 | 280 | def __new__(cls, url=None, key=None, *args, **kwargs): 281 | _, token, _ = get_url_key_verify(url, key, None) 282 | if ":" in token: 283 | return super().__new__(cls) 284 | 285 | from ecmwf.datastores.legacy_client import LegacyClient 286 | 287 | return super().__new__(LegacyClient) 288 | 289 | def __init__( 290 | self, 291 | url=None, 292 | key=None, 293 | quiet=False, 294 | debug=False, 295 | verify=None, 296 | timeout=60, 297 | progress=True, 298 | full_stack=False, 299 | delete=True, 300 | retry_max=500, 301 | sleep_max=120, 302 | wait_until_complete=True, 303 | info_callback=None, 304 | warning_callback=None, 305 | error_callback=None, 306 | debug_callback=None, 307 | metadata=None, 308 | forget=False, 309 | session=requests.Session(), 310 | ): 311 | if not quiet: 312 | if debug: 313 | level = logging.DEBUG 314 | else: 315 | level = logging.INFO 316 | 317 | self.logger.setLevel(level) 318 | 319 | # avoid duplicate handlers when creating more than one Client 320 | if not self.logger.handlers: 321 | formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") 322 | handler = logging.StreamHandler() 323 | handler.setFormatter(formatter) 324 | self.logger.addHandler(handler) 325 | 326 | url, key, verify = get_url_key_verify(url, key, verify) 327 | 328 | self.url = url 329 | self.key = key 330 | 331 | self.quiet = quiet 332 | self.progress = progress and not quiet 333 | 334 | self.verify = True if verify else False 335 | self.timeout = timeout 336 | self.sleep_max = sleep_max 337 | self.retry_max = retry_max 338 | self.full_stack = full_stack 339 | self.delete = delete 340 | self.last_state = None 341 | self.wait_until_complete = wait_until_complete 342 | 343 | self.debug_callback = debug_callback 344 | self.warning_callback = warning_callback 345 | self.info_callback = info_callback 346 | self.error_callback = error_callback 347 | 348 | self.session = session 349 | self.session.auth = tuple(self.key.split(":", 2)) 350 | self.session.headers = { 351 | "User-Agent": f"cdsapi/{version('cdsapi')}", 352 | } 353 | 354 | assert len(self.session.auth) == 2, ( 355 | "The cdsapi key provided is not the correct format, please ensure it conforms to:\n" 356 | ":" 357 | ) 358 | 359 | self.metadata = metadata 360 | self.forget = forget 361 | 362 | self.debug( 363 | "CDSAPI %s", 364 | dict( 365 | url=self.url, 366 | key=self.key, 367 | quiet=self.quiet, 368 | verify=self.verify, 369 | timeout=self.timeout, 370 | progress=self.progress, 371 | sleep_max=self.sleep_max, 372 | retry_max=self.retry_max, 373 | full_stack=self.full_stack, 374 | delete=self.delete, 375 | metadata=self.metadata, 376 | forget=self.forget, 377 | ), 378 | ) 379 | 380 | def retrieve(self, name, request, target=None): 381 | result = self._api("%s/resources/%s" % (self.url, name), request, "POST") 382 | if target is not None: 383 | result.download(target) 384 | return result 385 | 386 | def service(self, name, *args, **kwargs): 387 | self.delete = False # Don't delete results 388 | name = "/".join(name.split(".")) 389 | mimic_ui = kwargs.pop("mimic_ui", False) 390 | # To mimic the CDS ui the request should be populated directly with the kwargs 391 | if mimic_ui: 392 | request = kwargs 393 | else: 394 | request = dict(args=args, kwargs=kwargs) 395 | 396 | if self.metadata: 397 | request["_cds_metadata"] = self.metadata 398 | request = toJSON(request) 399 | result = self._api( 400 | "%s/tasks/services/%s/clientid-%s" % (self.url, name, uuid.uuid4().hex), 401 | request, 402 | "PUT", 403 | ) 404 | return result 405 | 406 | def workflow(self, code, *args, **kwargs): 407 | workflow_name = kwargs.pop("workflow_name", "application") 408 | params = dict(code=code, args=args, kwargs=kwargs, workflow_name=workflow_name) 409 | return self.service("tool.toolbox.orchestrator.run_workflow", params) 410 | 411 | def status(self, context=None): 412 | url = "%s/status.json" % (self.url,) 413 | r = self.session.get(url, verify=self.verify, timeout=self.timeout) 414 | r.raise_for_status() 415 | return r.json() 416 | 417 | def _status(self, url): 418 | try: 419 | status = self.status(url) 420 | 421 | info = status.get("info", []) 422 | if not isinstance(info, list): 423 | info = [info] 424 | for i in info: 425 | self.info("%s", i) 426 | 427 | warning = status.get("warning", []) 428 | if not isinstance(warning, list): 429 | warning = [warning] 430 | for w in warning: 431 | self.warning("%s", w) 432 | 433 | except Exception: 434 | pass 435 | 436 | def _api(self, url, request, method): 437 | self._status(url) 438 | 439 | session = self.session 440 | 441 | self.info("Sending request to %s", url) 442 | self.debug("%s %s %s", method, url, json.dumps(request)) 443 | 444 | if method == "PUT": 445 | action = session.put 446 | else: 447 | action = session.post 448 | 449 | result = self.robust(action)( 450 | url, json=request, verify=self.verify, timeout=self.timeout 451 | ) 452 | 453 | if self.forget: 454 | return result 455 | 456 | reply = None 457 | 458 | try: 459 | result.raise_for_status() 460 | reply = result.json() 461 | except Exception: 462 | if reply is None: 463 | try: 464 | reply = result.json() 465 | except Exception: 466 | reply = dict(message=result.text) 467 | 468 | self.debug(json.dumps(reply)) 469 | 470 | if "message" in reply: 471 | error = reply["message"] 472 | 473 | if "context" in reply and "required_terms" in reply["context"]: 474 | e = [error] 475 | for t in reply["context"]["required_terms"]: 476 | e.append( 477 | "To access this resource, you first need to accept the terms" 478 | "of '%s' at %s" % (t["title"], t["url"]) 479 | ) 480 | error = ". ".join(e) 481 | raise Exception(error) 482 | else: 483 | raise 484 | 485 | if not self.wait_until_complete: 486 | return Result(self, reply) 487 | 488 | sleep = 1 489 | 490 | while True: 491 | self.debug("REPLY %s", reply) 492 | 493 | if reply["state"] != self.last_state: 494 | self.info("Request is %s" % (reply["state"],)) 495 | self.last_state = reply["state"] 496 | 497 | if reply["state"] == "completed": 498 | self.debug("Done") 499 | 500 | if "result" in reply: 501 | return reply["result"] 502 | 503 | return Result(self, reply) 504 | 505 | if reply["state"] in ("queued", "running"): 506 | rid = reply["request_id"] 507 | 508 | self.debug("Request ID is %s, sleep %s", rid, sleep) 509 | time.sleep(sleep) 510 | sleep *= 1.5 511 | if sleep > self.sleep_max: 512 | sleep = self.sleep_max 513 | 514 | task_url = "%s/tasks/%s" % (self.url, rid) 515 | self.debug("GET %s", task_url) 516 | 517 | result = self.robust(session.get)( 518 | task_url, verify=self.verify, timeout=self.timeout 519 | ) 520 | result.raise_for_status() 521 | reply = result.json() 522 | continue 523 | 524 | if reply["state"] in ("failed",): 525 | self.error("Message: %s", reply["error"].get("message")) 526 | self.error("Reason: %s", reply["error"].get("reason")) 527 | for n in ( 528 | reply.get("error", {}) 529 | .get("context", {}) 530 | .get("traceback", "") 531 | .split("\n") 532 | ): 533 | if n.strip() == "" and not self.full_stack: 534 | break 535 | self.error(" %s", n) 536 | raise Exception( 537 | "%s. %s." 538 | % (reply["error"].get("message"), reply["error"].get("reason")) 539 | ) 540 | 541 | raise Exception("Unknown API state [%s]" % (reply["state"],)) 542 | 543 | def info(self, *args, **kwargs): 544 | if self.info_callback: 545 | self.info_callback(*args, **kwargs) 546 | else: 547 | self.logger.info(*args, **kwargs) 548 | 549 | def warning(self, *args, **kwargs): 550 | if self.warning_callback: 551 | self.warning_callback(*args, **kwargs) 552 | else: 553 | self.logger.warning(*args, **kwargs) 554 | 555 | def error(self, *args, **kwargs): 556 | if self.error_callback: 557 | self.error_callback(*args, **kwargs) 558 | else: 559 | self.logger.error(*args, **kwargs) 560 | 561 | def debug(self, *args, **kwargs): 562 | if self.debug_callback: 563 | self.debug_callback(*args, **kwargs) 564 | else: 565 | self.logger.debug(*args, **kwargs) 566 | 567 | def _download(self, results, targets=None): 568 | if isinstance(results, Result): 569 | if targets: 570 | path = targets.pop(0) 571 | else: 572 | path = None 573 | return results.download(path) 574 | 575 | if isinstance(results, (list, tuple)): 576 | return [self._download(x, targets) for x in results] 577 | 578 | if isinstance(results, dict): 579 | if "location" in results and "contentLength" in results: 580 | reply = dict( 581 | location=results["location"], 582 | content_length=results["contentLength"], 583 | content_type=results.get("contentType"), 584 | ) 585 | 586 | if targets: 587 | path = targets.pop(0) 588 | else: 589 | path = None 590 | 591 | return Result(self, reply).download(path) 592 | 593 | r = {} 594 | for k, v in results.items(): 595 | r[v] = self._download(v, targets) 596 | return r 597 | 598 | return results 599 | 600 | def download(self, results, targets=None): 601 | if targets: 602 | # Make a copy 603 | targets = [t for t in targets] 604 | return self._download(results, targets) 605 | 606 | def remote(self, url): 607 | r = requests.head(url) 608 | reply = dict( 609 | location=url, 610 | content_length=r.headers["Content-Length"], 611 | content_type=r.headers["Content-Type"], 612 | ) 613 | return Result(self, reply) 614 | 615 | def robust(self, call): 616 | def retriable(code, reason): 617 | if code in [ 618 | requests.codes.internal_server_error, 619 | requests.codes.bad_gateway, 620 | requests.codes.service_unavailable, 621 | requests.codes.gateway_timeout, 622 | requests.codes.too_many_requests, 623 | requests.codes.request_timeout, 624 | ]: 625 | return True 626 | 627 | return False 628 | 629 | def wrapped(*args, **kwargs): 630 | tries = 0 631 | while True: 632 | txt = "Error" 633 | try: 634 | resp = call(*args, **kwargs) 635 | except ( 636 | requests.exceptions.ConnectionError, 637 | requests.exceptions.ReadTimeout, 638 | ) as e: 639 | resp = None 640 | txt = f"Connection error: [{e}]" 641 | 642 | if resp is not None: 643 | if not retriable(resp.status_code, resp.reason): 644 | break 645 | try: 646 | self.warning(resp.json()["reason"]) 647 | except Exception: 648 | pass 649 | txt = f"HTTP error: [{resp.status_code} {resp.reason}]" 650 | 651 | tries += 1 652 | self.warning(txt + f". Attempt {tries} of {self.retry_max}.") 653 | if tries < self.retry_max: 654 | self.warning(f"Retrying in {self.sleep_max} seconds") 655 | time.sleep(self.sleep_max) 656 | self.info("Retrying now...") 657 | else: 658 | raise Exception("Could not connect") 659 | 660 | return resp 661 | 662 | return wrapped 663 | -------------------------------------------------------------------------------- /docker/.gitignore: -------------------------------------------------------------------------------- 1 | output/* 2 | 3 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine 2 | 3 | RUN pip3 install cdsapi 4 | WORKDIR /input 5 | COPY request.json request.json 6 | WORKDIR /output 7 | WORKDIR /app 8 | COPY retrieve.py retrieve.py 9 | 10 | CMD ["python", "retrieve.py"] 11 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | ## Simple wrapper around cdsapi 2 | 3 | cdsapi homepage : https://github.com/ecmwf/cdsapi 4 | 5 | ### How to use the dockerized version ? 6 | 7 | 1. Write a request in json file – don't forget the file format and name. Eg. 8 | 9 | ```js 10 | { 11 | "url": "https://cds.climate.copernicus.eu/api/v2", 12 | "uuid": "", 13 | "key": "", 14 | "variable": "reanalysis-era5-pressure-levels", 15 | "options": { 16 | "variable": "temperature", 17 | "pressure_level": "1000", 18 | "product_type": "reanalysis", 19 | "date": "2017-12-01/2017-12-31", 20 | "time": "12:00", 21 | "format": "grib" 22 | }, 23 | "filename":"test.grib" 24 | } 25 | ``` 26 | 27 | 2. Run the command 28 | 29 | ```sh 30 | docker run -it --rm \ 31 | -v $(pwd)/request.json:/input/request.json \ 32 | -v $(pwd)/.:/output \ 33 | /cdsretrieve 34 | ``` 35 | 36 | 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. 37 | 38 | 39 | -------------------------------------------------------------------------------- /docker/request.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://cds.climate.copernicus.eu/api/v2", 3 | "uuid": "< YOUR USER ID >", 4 | "key": "< YOUR API KEY >", 5 | "variable": "reanalysis-era5-pressure-levels", 6 | "options": { 7 | "variable": "temperature", 8 | "pressure_level": "1000", 9 | "product_type": "reanalysis", 10 | "date": "2017-12-01/2017-12-31", 11 | "time": "12:00", 12 | "format": "grib" 13 | }, 14 | "filename":"test.grib" 15 | } 16 | -------------------------------------------------------------------------------- /docker/retrieve.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import cdsapi 4 | 5 | with open("/input/request.json") as req: 6 | request = json.load(req) 7 | 8 | cds = cdsapi.Client(request.get("url"), request.get("uuid") + ":" + request.get("key")) 9 | 10 | cds.retrieve( 11 | request.get("variable"), 12 | request.get("options"), 13 | "/output/" + request.get("filename"), 14 | ) 15 | -------------------------------------------------------------------------------- /example-era5.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # (C) Copyright 2018 ECMWF. 4 | # 5 | # This software is licensed under the terms of the Apache Licence Version 2.0 6 | # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. 7 | # In applying this licence, ECMWF does not waive the privileges and immunities 8 | # granted to it by virtue of its status as an intergovernmental organisation nor 9 | # does it submit to any jurisdiction. 10 | 11 | import cdsapi 12 | 13 | c = cdsapi.Client() 14 | 15 | r = c.retrieve( 16 | "reanalysis-era5-single-levels", 17 | { 18 | "variable": "2t", 19 | "product_type": "reanalysis", 20 | "date": "2012-12-01", 21 | "time": "14:00", 22 | "format": "netcdf", 23 | }, 24 | ) 25 | 26 | r.download("test.nc") 27 | -------------------------------------------------------------------------------- /example-glaciers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # (C) Copyright 2018 ECMWF. 4 | # 5 | # This software is licensed under the terms of the Apache Licence Version 2.0 6 | # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. 7 | # In applying this licence, ECMWF does not waive the privileges and immunities 8 | # granted to it by virtue of its status as an intergovernmental organisation nor 9 | # does it submit to any jurisdiction. 10 | 11 | import cdsapi 12 | 13 | c = cdsapi.Client() 14 | 15 | c.retrieve( 16 | "insitu-glaciers-elevation-mass", 17 | {"variable": "elevation_change", "format": "tgz"}, 18 | "dowload.data", 19 | ) 20 | -------------------------------------------------------------------------------- /examples/example-era5-update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # (C) Copyright 2018 ECMWF. 4 | # 5 | # This software is licensed under the terms of the Apache Licence Version 2.0 6 | # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0. 7 | # In applying this licence, ECMWF does not waive the privileges and immunities 8 | # granted to it by virtue of its status as an intergovernmental organisation nor 9 | # does it submit to any jurisdiction. 10 | 11 | import time 12 | 13 | import cdsapi 14 | 15 | c = cdsapi.Client(debug=True, wait_until_complete=False) 16 | 17 | r = c.retrieve( 18 | "reanalysis-era5-single-levels", 19 | { 20 | "variable": "2t", 21 | "product_type": "reanalysis", 22 | "date": "2015-12-01", 23 | "time": "14:00", 24 | "format": "netcdf", 25 | }, 26 | ) 27 | 28 | sleep = 30 29 | while True: 30 | r.update() 31 | reply = r.reply 32 | r.info("Request ID: %s, state: %s" % (reply["request_id"], reply["state"])) 33 | 34 | if reply["state"] == "completed": 35 | break 36 | elif reply["state"] in ("queued", "running"): 37 | r.info("Request ID: %s, sleep: %s", reply["request_id"], sleep) 38 | time.sleep(sleep) 39 | elif reply["state"] in ("failed",): 40 | r.error("Message: %s", reply["error"].get("message")) 41 | r.error("Reason: %s", reply["error"].get("reason")) 42 | for n in ( 43 | reply.get("error", {}).get("context", {}).get("traceback", "").split("\n") 44 | ): 45 | if n.strip() == "": 46 | break 47 | r.error(" %s", n) 48 | raise Exception( 49 | "%s. %s." % (reply["error"].get("message"), reply["error"].get("reason")) 50 | ) 51 | 52 | r.download("test.nc") 53 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [aliases] 5 | test = pytest 6 | 7 | [tool:pytest] 8 | norecursedirs = 9 | build 10 | dist 11 | .tox 12 | .eggs 13 | pep8maxlinelength = 109 14 | mccabe-complexity = 10 15 | 16 | [coverage:run] 17 | branch = True 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright 2018 European Centre for Medium-Range Weather Forecasts (ECMWF) 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # In applying this licence, ECMWF does not waive the privileges and immunities 18 | # granted to it by virtue of its status as an intergovernmental organisation nor 19 | # does it submit to any jurisdiction. 20 | 21 | 22 | import io 23 | import os.path 24 | 25 | import setuptools 26 | 27 | 28 | def read(fname): 29 | file_path = os.path.join(os.path.dirname(__file__), fname) 30 | return io.open(file_path, encoding="utf-8").read() 31 | 32 | 33 | version = "0.7.7.dev0" 34 | 35 | 36 | setuptools.setup( 37 | name="cdsapi", 38 | version=version, 39 | author="ECMWF", 40 | author_email="software.support@ecmwf.int", 41 | license="Apache 2.0", 42 | url="https://github.com/ecmwf/cdsapi", 43 | description="Climate Data Store API", 44 | long_description=read("README.rst"), 45 | packages=setuptools.find_packages(), 46 | include_package_data=True, 47 | python_requires=">=3.8", 48 | install_requires=[ 49 | "ecmwf-datastores-client", 50 | "requests>=2.5.0", 51 | "tqdm", 52 | ], 53 | zip_safe=True, 54 | classifiers=[ 55 | "Development Status :: 4 - Beta", 56 | "Intended Audience :: Developers", 57 | "Programming Language :: Python", 58 | "Programming Language :: Python :: 3", 59 | "Programming Language :: Python :: 3.9", 60 | "Programming Language :: Python :: 3.10", 61 | "Programming Language :: Python :: 3.11", 62 | "Programming Language :: Python :: 3.12", 63 | "Programming Language :: Python :: 3.13", 64 | "Programming Language :: Python :: Implementation :: CPython", 65 | "Programming Language :: Python :: Implementation :: PyPy", 66 | "Operating System :: OS Independent", 67 | ], 68 | ) 69 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | pytest-cov 3 | pytest-env 4 | pytest-flakes 5 | pytest-mccabe 6 | pytest-pep8 7 | pytest-runner 8 | pytest 9 | requests 10 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import ecmwf.datastores.legacy_client 4 | import pytest 5 | 6 | import cdsapi 7 | 8 | 9 | def test_request(): 10 | c = cdsapi.Client() 11 | 12 | r = c.retrieve( 13 | "reanalysis-era5-single-levels", 14 | { 15 | "variable": "2t", 16 | "product_type": "reanalysis", 17 | "date": "2012-12-01", 18 | "time": "12:00", 19 | }, 20 | ) 21 | 22 | r.download("test.grib") 23 | 24 | assert os.path.getsize("test.grib") == 2076588 25 | 26 | 27 | @pytest.mark.parametrize( 28 | "key,expected_client", 29 | [ 30 | ( 31 | ":", 32 | cdsapi.Client, 33 | ), 34 | ( 35 | "", 36 | ecmwf.datastores.legacy_client.LegacyClient, 37 | ), 38 | ], 39 | ) 40 | @pytest.mark.parametrize("key_from_env", [True, False]) 41 | def test_instantiation(monkeypatch, key, expected_client, key_from_env): 42 | if key_from_env: 43 | monkeypatch.setenv("CDSAPI_KEY", key) 44 | c = cdsapi.Client() 45 | else: 46 | c = cdsapi.Client(key=key) 47 | assert isinstance(c, cdsapi.Client) 48 | assert isinstance(c, expected_client) 49 | assert c.key == key 50 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = qc, py312, py311, py310, py39, py38, pypy3, pypy, deps 3 | 4 | [testenv] 5 | setenv = PYTHONPATH = {toxinidir} 6 | deps = -r{toxinidir}/tests/requirements-tests.txt 7 | commands = pytest -v --flakes --cache-clear --basetemp={envtmpdir} {posargs} 8 | 9 | [testenv:qc] 10 | # needed for pytest-cov 11 | usedevelop = true 12 | commands = pytest -v --pep8 --mccabe --cov=cdsapi --cov-report=html --cache-clear {posargs} 13 | 14 | [testenv:deps] 15 | deps = 16 | commands = python setup.py test 17 | 18 | 19 | [black] 20 | line_length=120 21 | [isort] 22 | profile=black 23 | [flake8] 24 | max-line-length = 120 25 | --------------------------------------------------------------------------------