pax_global_header00006660000000000000000000000064146700355660014526gustar00rootroot0000000000000052 comment=3316beadf484be3fe835fa25595ec7e57417cb50 cads-api-client-1.3.2/000077500000000000000000000000001467003556600144665ustar00rootroot00000000000000cads-api-client-1.3.2/.cruft.json000066400000000000000000000011431467003556600165610ustar00rootroot00000000000000{ "template": "https://github.com/ecmwf-projects/cookiecutter-conda-package", "commit": "53d181cf85978d6dcee634173c62af7d6b4a8dae", "checkout": null, "context": { "cookiecutter": { "project_name": "cads-api-client", "project_slug": "cads_api_client", "project_short_description": "CADS API Python client", "copyright_holder": "European Union", "copyright_year": "2022", "mypy_strict": "True", "integration_tests": "True", "pypi": true, "_template": "https://github.com/ecmwf-projects/cookiecutter-conda-package" } }, "directory": null } cads-api-client-1.3.2/.github/000077500000000000000000000000001467003556600160265ustar00rootroot00000000000000cads-api-client-1.3.2/.github/workflows/000077500000000000000000000000001467003556600200635ustar00rootroot00000000000000cads-api-client-1.3.2/.github/workflows/on-push.yml000066400000000000000000000144771467003556600222140ustar00rootroot00000000000000name: on-push on: push: branches: - main tags: - '*' pull_request: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true defaults: run: shell: bash -l {0} jobs: pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.x - uses: pre-commit/action@v3.0.1 combine-environments: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.x - name: Install conda-merge run: | python -m pip install conda-merge - name: Combine environments run: | for SUFFIX in ci integration; do conda-merge ci/environment-$SUFFIX.yml environment.yml > ci/combined-environment-$SUFFIX.yml || exit done - uses: actions/upload-artifact@v4 with: name: combined-environments path: ci/combined-environment-*.yml unit-tests: name: unit-tests needs: combine-environments runs-on: ubuntu-latest strategy: matrix: python-version: ['3.11'] steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: combined-environments path: ci - name: Get current date id: date run: echo "date=$(date +%Y-%m-%d)" >> "${GITHUB_OUTPUT}" - uses: mamba-org/setup-micromamba@v1 with: environment-file: ci/combined-environment-ci.yml environment-name: DEVELOP cache-environment: true cache-environment-key: environment-${{ steps.date.outputs.date }} cache-downloads-key: downloads-${{ steps.date.outputs.date }} create-args: >- python=${{ matrix.python-version }} - name: Install package run: | python -m pip install --no-deps -e . - name: Run tests run: | make unit-tests COV_REPORT=xml type-check: needs: [combine-environments, unit-tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: combined-environments path: ci - name: Get current date id: date run: echo "date=$(date +%Y-%m-%d)" >> "${GITHUB_OUTPUT}" - uses: mamba-org/setup-micromamba@v1 with: environment-file: ci/combined-environment-ci.yml environment-name: DEVELOP cache-environment: true cache-environment-key: environment-${{ steps.date.outputs.date }} cache-downloads-key: downloads-${{ steps.date.outputs.date }} create-args: >- python=3.11 - name: Install package run: | python -m pip install --no-deps -e . - name: Run code quality checks run: | make type-check docs-build: needs: [combine-environments, unit-tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: combined-environments path: ci - name: Get current date id: date run: echo "date=$(date +%Y-%m-%d)" >> "${GITHUB_OUTPUT}" - uses: mamba-org/setup-micromamba@v1 with: environment-file: ci/combined-environment-ci.yml environment-name: DEVELOP cache-environment: true cache-environment-key: environment-${{ steps.date.outputs.date }} cache-downloads-key: downloads-${{ steps.date.outputs.date }} create-args: >- python=3.11 - name: Install package run: | python -m pip install --no-deps -e . - name: Build documentation run: | make docs-build integration-tests: needs: [combine-environments, unit-tests] if: | success() && true runs-on: ubuntu-latest strategy: matrix: python-version: ['3.8', '3.9', '3.10', '3.12'] extra: ['-ci'] steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: combined-environments path: ci - name: Get current date id: date run: echo "date=$(date +%Y-%m-%d)" >> "${GITHUB_OUTPUT}" - uses: mamba-org/setup-micromamba@v1 with: environment-file: ci/combined-environment${{ matrix.extra }}.yml environment-name: DEVELOP${{ matrix.extra }} cache-environment: true cache-environment-key: environment-${{ steps.date.outputs.date }} cache-downloads-key: downloads-${{ steps.date.outputs.date }} create-args: >- python=${{ matrix.python-version }} - name: Install package run: | python -m pip install --no-deps -e . - name: Run tests run: | make unit-tests COV_REPORT=xml distribution: runs-on: ubuntu-latest needs: [unit-tests, type-check, docs-build, integration-tests] if: | always() && needs.unit-tests.result == 'success' && needs.type-check.result == 'success' && needs.docs-build.result == 'success' && (needs.integration-tests.result == 'success' || needs.integration-tests.result == 'skipped') steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install package run: | python -m pip install --upgrade pip python -m pip install build twine - name: Build distribution run: | python -m build - name: Check wheels run: | cd dist || exit python -m pip install cads_api_client*.whl || exit python -m twine check --strict * || exit python -c "import cads_api_client" || exit cd .. - uses: actions/upload-artifact@v4 with: name: distribution path: dist upload-to-pypi: runs-on: ubuntu-latest needs: distribution if: | always() && true && needs.distribution.result == 'success' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') environment: name: pypi url: https://pypi.org/p/cads-api-client permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publish steps: - uses: actions/download-artifact@v4 with: name: distribution path: dist - uses: pypa/gh-action-pypi-publish@v1.10.0 with: verbose: true cads-api-client-1.3.2/.gitignore000066400000000000000000000211731467003556600164620ustar00rootroot00000000000000# setuptools-scm version.py # Sphinx automatic generation of API docs/_api/ # Combined environments ci/combined-environment-*.yml # download tests *.grib # Created by https://www.toptal.com/developers/gitignore/api/python,jupyternotebooks,vim,visualstudiocode,pycharm,emacs,linux,macos,windows # Edit at https://www.toptal.com/developers/gitignore?templates=python,jupyternotebooks,vim,visualstudiocode,pycharm,emacs,linux,macos,windows ### Emacs ### # -*- mode: gitignore; -*- *~ \#*\# /.emacs.desktop /.emacs.desktop.lock *.elc auto-save-list tramp .\#* # Org-mode .org-id-locations *_archive # flymake-mode *_flymake.* # eshell files /eshell/history /eshell/lastdir # elpa packages /elpa/ # reftex files *.rel # AUCTeX auto folder /auto/ # cask packages .cask/ dist/ # Flycheck flycheck_*.el # server auth directory /server/ # projectiles files .projectile # directory configuration .dir-locals.el # network security /network-security.data ### JupyterNotebooks ### # gitignore template for Jupyter Notebooks # website: http://jupyter.org/ .ipynb_checkpoints */.ipynb_checkpoints/* # IPython profile_default/ ipython_config.py # Remove previous ipynb_checkpoints # git rm -r .ipynb_checkpoints/ ### Linux ### # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* ### macOS ### # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### macOS Patch ### # iCloud generated files *.icloud ### PyCharm ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf # AWS User-specific .idea/**/aws.xml # Generated files .idea/**/contentModel.xml # Sensitive or high-churn files .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml # Gradle .idea/**/gradle.xml .idea/**/libraries # Gradle and Maven with auto-import # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using # auto-import. # .idea/artifacts # .idea/compiler.xml # .idea/jarRepositories.xml # .idea/modules.xml # .idea/*.iml # .idea/modules # *.iml # *.ipr # CMake cmake-build-*/ # Mongo Explorer plugin .idea/**/mongoSettings.xml # File-based project format *.iws # IntelliJ out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml # SonarLint plugin .idea/sonarlint/ # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties # Editor-based Rest Client .idea/httpRequests # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser ### PyCharm Patch ### # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 # *.iml # modules.xml # .idea/misc.xml # *.ipr # Sonarlint plugin # https://plugins.jetbrains.com/plugin/7973-sonarlint .idea/**/sonarlint/ # SonarQube Plugin # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin .idea/**/sonarIssues.xml # Markdown Navigator plugin # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced .idea/**/markdown-navigator.xml .idea/**/markdown-navigator-enh.xml .idea/**/markdown-navigator/ # Cache file creation bug # See https://youtrack.jetbrains.com/issue/JBR-2257 .idea/$CACHE_FILE$ # CodeStream plugin # https://plugins.jetbrains.com/plugin/12206-codestream .idea/codestream.xml # Azure Toolkit for IntelliJ plugin # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij .idea/**/azureSettings.xml ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook # IPython # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ### Python Patch ### # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration poetry.toml # ruff .ruff_cache/ # LSP config files pyrightconfig.json ### Vim ### # Swap [._]*.s[a-v][a-z] !*.svg # comment out if you don't need vector files [._]*.sw[a-p] [._]s[a-rt-v][a-z] [._]ss[a-gi-z] [._]sw[a-p] # Session Session.vim Sessionx.vim # Temporary .netrwhist # Auto-generated tag files tags # Persistent undo [._]*.un~ ### VisualStudioCode ### .vscode/ # .vscode/* # !.vscode/settings.json # !.vscode/tasks.json # !.vscode/launch.json # !.vscode/extensions.json # !.vscode/*.code-snippets # Local History for Visual Studio Code .history/ # Built Visual Studio Code Extensions *.vsix ### VisualStudioCode Patch ### # Ignore all local history of files .history .ionide ### Windows ### # Windows thumbnail cache files Thumbs.db Thumbs.db:encryptable ehthumbs.db ehthumbs_vista.db # Dump file *.stackdump # Folder config file [Dd]esktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msix *.msm *.msp # Windows shortcuts *.lnk # End of https://www.toptal.com/developers/gitignore/api/python,jupyternotebooks,vim,visualstudiocode,pycharm,emacs,linux,macos,windows cads-api-client-1.3.2/.pre-commit-config-cruft.yaml000066400000000000000000000002221467003556600220640ustar00rootroot00000000000000repos: - repo: https://github.com/cruft/cruft rev: 2.15.0 hooks: - id: cruft entry: cruft update -y additional_dependencies: [toml] cads-api-client-1.3.2/.pre-commit-config.yaml000066400000000000000000000017151467003556600207530ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-json - id: check-yaml - id: check-toml - id: check-added-large-files - id: check-merge-conflict - id: debug-statements - id: mixed-line-ending - repo: https://github.com/keewis/blackdoc rev: v0.3.9 hooks: - id: blackdoc additional_dependencies: [black==23.11.0] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.4 hooks: - id: ruff args: [--fix, --show-fixes] - id: ruff-format - repo: https://github.com/executablebooks/mdformat rev: 0.7.17 hooks: - id: mdformat - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.14.0 hooks: - id: pretty-format-yaml args: [--autofix, --preserve-quotes] - id: pretty-format-toml args: [--autofix] - repo: https://github.com/gitleaks/gitleaks rev: v8.18.4 hooks: - id: gitleaks cads-api-client-1.3.2/Dockerfile000066400000000000000000000004121467003556600164550ustar00rootroot00000000000000FROM continuumio/miniconda3 WORKDIR /src/cads-api-client COPY environment.yml /src/cads-api-client/ RUN conda install -c conda-forge gcc python=3.11 \ && conda env update -n base -f environment.yml COPY . /src/cads-api-client RUN pip install --no-deps -e . cads-api-client-1.3.2/LICENSE000066400000000000000000000261221467003556600154760ustar00rootroot00000000000000 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 APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2022, European Union. 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. cads-api-client-1.3.2/Makefile000066400000000000000000000017431467003556600161330ustar00rootroot00000000000000PROJECT := cads_api_client CONDA := conda CONDAFLAGS := COV_REPORT := html default: qa unit-tests type-check qa: pre-commit run --all-files unit-tests: python -m pytest -vv --cov=. --cov-report=$(COV_REPORT) type-check: python -m mypy . conda-env-update: $(CONDA) install -y -c conda-forge conda-merge $(CONDA) run conda-merge environment.yml ci/environment-ci.yml > ci/combined-environment-ci.yml $(CONDA) env update $(CONDAFLAGS) -f ci/combined-environment-ci.yml docker-build: docker build -t $(PROJECT) . docker-run: docker run --rm -ti -v $(PWD):/srv $(PROJECT) template-update: pre-commit run --all-files cruft -c .pre-commit-config-cruft.yaml docs-build: cd docs && rm -fr _api && make clean && make html # DO NOT EDIT ABOVE THIS LINE, ADD COMMANDS BELOW integration-tests: python -m pytest -vv --cov=. --cov-report=$(COV_REPORT) tests/integration*.py doc-tests: python -m pytest -vv --doctest-glob='*.md' README.md all-tests: default integration-tests doc-tests cads-api-client-1.3.2/README.md000066400000000000000000000052711467003556600157520ustar00rootroot00000000000000# cads-api-client CADS API Python client The `ApiClient` needs the `url` to the API root and a valid API `key` to access protected resources. You can also set the `CADS_API_URL` and `CADS_API_KEY` environment variables. It is possible (but not recommended) to use the API key of one of the test users, `00112233-4455-6677-c899-aabbccddeeff`. This is used in anonymous tests and it is designed to be the least performant option to access the system. Draft Python API: ```python >>> import cads_api_client >>> client = cads_api_client.ApiClient() >>> assert client.check_authentication() >>> collection = client.collection("reanalysis-era5-pressure-levels") >>> collection.end_datetime datetime.datetime(...) >>> client.retrieve( ... collection_id="reanalysis-era5-pressure-levels", ... product_type="reanalysis", ... variable="temperature", ... year="2022", ... month="01", ... day="01", ... level="1000", ... time="00:00", ... target="tmp1-era5.grib", ... ) # blocks 'tmp1-era5.grib' >>> remote = client.submit( ... collection_id="reanalysis-era5-pressure-levels", ... variable="temperature", ... product_type="reanalysis", ... year="2021", ... month="01", ... day="02", ... time="00:00", ... level="1000", ... ) # doesn't block >>> remote.request_uid '...' >>> remote.status '...' >>> remote.download("tmp2-era5.grib") # blocks 'tmp2-era5.grib' ``` ## Workflow for developers/contributors For best experience create a new conda environment (e.g. DEVELOP) with Python 3.11: ``` conda create -n DEVELOP -c conda-forge python=3.11 conda activate DEVELOP ``` Before pushing to GitHub, run the following commands: 1. Update conda environment: `make conda-env-update` 1. Install this package: `pip install -e .` 1. Sync with the latest [template](https://github.com/ecmwf-projects/cookiecutter-conda-package) (optional): `make template-update` 1. Run quality assurance checks: `make qa` 1. Run tests: `make unit-tests` 1. Run the static type checker: `make type-check` 1. Build the documentation (see [Sphinx tutorial](https://www.sphinx-doc.org/en/master/tutorial/)): `make docs-build` ## License ``` Copyright 2022, European Union. 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. ``` cads-api-client-1.3.2/cads_api_client/000077500000000000000000000000001467003556600175675ustar00rootroot00000000000000cads-api-client-1.3.2/cads_api_client/__init__.py000066400000000000000000000021251467003556600217000ustar00rootroot00000000000000"""CADS API Python client.""" # Copyright 2022, European Union. # # 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. try: # NOTE: the `version.py` file must not be present in the git repository # as it is generated by setuptools at install time from .version import __version__ except ImportError: # pragma: no cover # Local copy or not installed with setuptools __version__ = "999" from .api_client import ApiClient from .catalogue import Catalogue from .processing import Processing, Remote, Results __all__ = ["__version__", "ApiClient", "Catalogue", "Processing", "Remote", "Results"] cads-api-client-1.3.2/cads_api_client/api_client.py000066400000000000000000000131231467003556600222500ustar00rootroot00000000000000from __future__ import annotations import functools import warnings from typing import Any import attrs import multiurl.base import requests from . import catalogue, config, processing, profile def strtobool(value: str) -> bool: if value.lower() in ("y", "yes", "t", "true", "on", "1"): return True if value.lower() in ("n", "no", "f", "false", "off", "0"): return False raise ValueError(f"invalid truth value {value!r}") @attrs.define(slots=False) class ApiClient: url: str | None = None key: str | None = None verify: bool | None = None timeout: int = 60 progress: bool = True cleanup: bool = False sleep_max: int = 120 retry_after: int = 120 maximum_tries: int = 500 session: requests.Session = attrs.field(factory=requests.Session) def __attrs_post_init__(self) -> None: if self.url is None: self.url = str(config.get_config("url")) if self.key is None: try: self.key = str(config.get_config("key")) except (KeyError, FileNotFoundError): warnings.warn("The API key is missing", UserWarning) if self.verify is None: try: self.verify = strtobool(str(config.get_config("verify"))) except (KeyError, FileNotFoundError): self.verify = True def _get_headers(self, key_is_mandatory: bool = True) -> dict[str, str]: if self.key is None: if key_is_mandatory: raise ValueError("The API key is needed to access this resource") return {} return {"PRIVATE-TOKEN": self.key} @property def _retry_options(self) -> dict[str, Any]: return { "maximum_tries": self.maximum_tries, "retry_after": self.retry_after, } @property def _download_options(self) -> dict[str, Any]: progress_bar = ( multiurl.base.progress_bar if self.progress else multiurl.base.NoBar ) return { "progress_bar": progress_bar, } @property def _request_options(self) -> dict[str, Any]: return { "timeout": self.timeout, "verify": self.verify, } def _get_request_kwargs( self, mandatory_key: bool = True ) -> processing.RequestKwargs: return processing.RequestKwargs( headers=self._get_headers(key_is_mandatory=mandatory_key), session=self.session, retry_options=self._retry_options, request_options=self._request_options, download_options=self._download_options, sleep_max=self.sleep_max, cleanup=self.cleanup, ) @functools.cached_property def catalogue_api(self) -> catalogue.Catalogue: return catalogue.Catalogue( f"{self.url}/catalogue", **self._get_request_kwargs(mandatory_key=False), ) @functools.cached_property def retrieve_api(self) -> processing.Processing: return processing.Processing( f"{self.url}/retrieve", **self._get_request_kwargs() ) @functools.cached_property def profile_api(self) -> profile.Profile: return profile.Profile(f"{self.url}/profiles", **self._get_request_kwargs()) def check_authentication(self) -> dict[str, Any]: return self.profile_api.check_authentication() def collections(self, **params: dict[str, Any]) -> catalogue.Collections: return self.catalogue_api.collections(params=params) def collection(self, collection_id: str) -> catalogue.Collection: return self.catalogue_api.collection(collection_id) def processes(self, **params: dict[str, Any]) -> processing.ProcessList: return self.retrieve_api.processes(params=params) def process(self, process_id: str) -> processing.Process: return self.retrieve_api.process(process_id=process_id) def submit(self, collection_id: str, **request: Any) -> processing.Remote: return self.retrieve_api.submit(collection_id, **request) def submit_and_wait_on_result( self, collection_id: str, **request: Any ) -> processing.Results: return self.retrieve_api.submit_and_wait_on_result(collection_id, **request) def retrieve( self, collection_id: str, target: str | None = None, **request: Any, ) -> str: result = self.submit_and_wait_on_result(collection_id, **request) return result.download(target) def get_requests(self, **params: dict[str, Any]) -> processing.JobList: return self.retrieve_api.jobs(params=params) def get_request(self, request_uid: str) -> processing.StatusInfo: return self.retrieve_api.job(request_uid) def get_remote(self, request_uid: str) -> processing.Remote: request = self.get_request(request_uid=request_uid) return request.make_remote() def download_result(self, request_uid: str, target: str | None) -> str: return self.retrieve_api.download_result(request_uid, target) def valid_values( self, collection_id: str, request: dict[str, Any] ) -> dict[str, Any]: process = self.retrieve_api.process(collection_id) return process.valid_values(request) @property def licences(self) -> dict[str, Any]: return self.catalogue_api.licenses() @property def accepted_licences(self) -> dict[str, Any]: return self.profile_api.accepted_licences() def accept_licence(self, licence_id: str, revision: int) -> dict[str, Any]: return self.profile_api.accept_licence(licence_id, revision=revision) cads-api-client-1.3.2/cads_api_client/catalogue.py000066400000000000000000000067631467003556600221210ustar00rootroot00000000000000from __future__ import annotations import datetime from typing import Any try: from typing import Self except ImportError: from typing_extensions import Self import attrs import requests from . import config, processing @attrs.define class Collections(processing.ApiResponse): def collection_ids(self) -> list[str]: return [collection["id"] for collection in self.json["collections"]] def next(self) -> Self | None: return self.from_rel_href(rel="next") def prev(self) -> Self | None: return self.from_rel_href(rel="prev") @attrs.define class Collection(processing.ApiResponse): @property def temporal_interval(self) -> tuple[str, str]: begin, end = map(str, self.json["extent"]["temporal"]["interval"][0]) return (begin, end) @property def begin_datetime(self) -> datetime.datetime: return datetime.datetime.fromisoformat( self.temporal_interval[0].replace("Z", "+00:00") ) @property def end_datetime(self) -> datetime.datetime: return datetime.datetime.fromisoformat( self.temporal_interval[1].replace("Z", "+00:00") ) @property def id(self) -> str: collection_id = self.json["id"] assert isinstance(collection_id, str) return collection_id def retrieve_process( self, headers: dict[str, str] | None = None ) -> processing.Process: url = self.get_link_href(rel="retrieve") kwargs = self.request_kwargs if headers is not None: kwargs["headers"] = headers return processing.Process.from_request("get", url, **kwargs) def submit( self, **request: Any, ) -> processing.Remote: retrieve_process = self.retrieve_process(headers=self.headers) status_info = retrieve_process.execute(inputs=request) return status_info.make_remote() def retrieve( self, target: str | None = None, **request: Any, ) -> str: remote = self.submit(**request) return remote.download(target) @attrs.define(slots=False) class Catalogue: url: str headers: dict[str, Any] session: requests.Session retry_options: dict[str, Any] request_options: dict[str, Any] download_options: dict[str, Any] sleep_max: int cleanup: bool force_exact_url: bool = False def __attrs_post_init__(self) -> None: if not self.force_exact_url: self.url += f"/{config.SUPPORTED_API_VERSION}" @property def request_kwargs(self) -> processing.RequestKwargs: return processing.RequestKwargs( headers=self.headers, session=self.session, retry_options=self.retry_options, request_options=self.request_options, download_options=self.download_options, sleep_max=self.sleep_max, cleanup=self.cleanup, ) def collections(self, params: dict[str, Any] = {}) -> Collections: url = f"{self.url}/datasets" return Collections.from_request( "get", url, params=params, **self.request_kwargs ) def collection(self, collection_id: str) -> Collection: url = f"{self.url}/collections/{collection_id}" return Collection.from_request("get", url, **self.request_kwargs) def licenses(self) -> dict[str, Any]: url = f"{self.url}/vocabularies/licences" return processing.ApiResponse.from_request( "get", url, **self.request_kwargs ).json cads-api-client-1.3.2/cads_api_client/config.py000066400000000000000000000014421467003556600214070ustar00rootroot00000000000000from __future__ import annotations import json import os from typing import Any SUPPORTED_API_VERSION = "v1" def read_configuration_file(config_path: str | None = None) -> dict[Any, Any]: if config_path is None: config_path = os.getenv("CADS_API_RC", "~/.cads-api-client.json") config_path = os.path.expanduser(config_path) try: with open(config_path) as fin: config = json.load(fin) assert isinstance(config, dict) except FileNotFoundError: raise except Exception: raise ValueError(f"failed to parse {config_path!r} file") return config def get_config(key: str, config_path: str | None = None) -> Any: return ( os.getenv(f"CADS_API_{key.upper()}") or read_configuration_file(config_path)[key] ) cads-api-client-1.3.2/cads_api_client/legacy_api_client.py000066400000000000000000000161021467003556600235740ustar00rootroot00000000000000from __future__ import annotations import functools import logging import warnings from types import TracebackType from typing import Any, Callable, TypeVar, cast, overload import cdsapi.api import requests from . import __version__ as cads_api_client_version from . import api_client, processing LEGACY_KWARGS = [ "full_stack", "delete", "retry_max", "sleep_max", "wait_until_complete", "info_callback", "warning_callback", "error_callback", "debug_callback", "metadata", "forget", "session", ] LOGGER = logging.getLogger(__name__) F = TypeVar("F", bound=Callable[..., Any]) class LoggingContext: def __init__(self, logger: logging.Logger, quiet: bool, debug: bool) -> None: self.old_level = logger.level if quiet: logger.setLevel(logging.WARNING) else: logger.setLevel(logging.DEBUG if debug else logging.INFO) self.new_handlers = [] if not logger.handlers: formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") handler = logging.StreamHandler() handler.setFormatter(formatter) logger.addHandler(handler) self.new_handlers.append(handler) self.logger = logger def __enter__(self) -> logging.Logger: return self.logger def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: self.logger.setLevel(self.old_level) for handler in self.new_handlers: self.logger.removeHandler(handler) class LegacyApiClient(cdsapi.api.Client): # type: ignore[misc] def __init__( self, url: str | None = None, key: str | None = None, quiet: bool = False, debug: bool = False, verify: bool | None = None, timeout: int = 60, progress: bool = True, *args: Any, **kwargs: Any, ) -> None: kwargs.update(zip(LEGACY_KWARGS, args)) if wrong_kwargs := set(kwargs) - set(LEGACY_KWARGS): raise ValueError(f"Wrong parameters: {wrong_kwargs}.") self.url, self.key, self.verify = cdsapi.api.get_url_key_verify( url, key, verify ) self.quiet = quiet self._debug = debug self.timeout = timeout self.progress = progress self.sleep_max = kwargs.pop("sleep_max", 120) self.wait_until_complete = kwargs.pop("wait_until_complete", True) self.delete = kwargs.pop("delete", False) self.retry_max = kwargs.pop("retry_max", 500) self.session = kwargs.pop("session", requests.Session()) if kwargs: warnings.warn( "This is a beta version." f" The following parameters have not been implemented yet: {kwargs}.", UserWarning, ) self.client = api_client.ApiClient( url=self.url, key=self.key, verify=self.verify, sleep_max=self.sleep_max, session=self.session, cleanup=self.delete, maximum_tries=self.retry_max, retry_after=self.sleep_max, timeout=self.timeout, progress=self.progress, ) self.debug( "CDSAPI %s", { "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, "delete": self.delete, "cads_api_client_version": cads_api_client_version, }, ) @classmethod def raise_not_implemented_error(self) -> None: raise NotImplementedError( "This is a beta version. This functionality has not been implemented yet." ) def logging_decorator(self, func: F) -> F: @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: with LoggingContext( logger=processing.logger, quiet=self.quiet, debug=self._debug ): return func(*args, **kwargs) return cast(F, wrapper) @overload def retrieve(self, name: str, request: dict[str, Any], target: str) -> str: ... @overload def retrieve( self, name: str, request: dict[str, Any], target: None = ... ) -> processing.Results: ... def retrieve( self, name: str, request: dict[str, Any], target: str | None = None ) -> str | processing.Remote | processing.Results: submitted: processing.Remote | processing.Results if self.wait_until_complete: submitted = self.logging_decorator(self.client.submit_and_wait_on_result)( collection_id=name, **request, ) else: submitted = self.logging_decorator(self.client.submit)( collection_id=name, **request, ) # Decorate legacy methods submitted.download = self.logging_decorator(submitted.download) # type: ignore[method-assign] submitted.info = self.logging_decorator(submitted.info) # type: ignore[method-assign] submitted.warning = self.logging_decorator(submitted.warning) # type: ignore[method-assign] submitted.error = self.logging_decorator(submitted.error) # type: ignore[method-assign] submitted.debug = self.logging_decorator(submitted.debug) # type: ignore[method-assign] return submitted if target is None else submitted.download(target) def info(self, *args: Any, **kwargs: Any) -> None: with LoggingContext( logger=LOGGER, quiet=self.quiet, debug=self._debug ) as logger: logger.info(*args, **kwargs) def warning(self, *args: Any, **kwargs: Any) -> None: with LoggingContext( logger=LOGGER, quiet=self.quiet, debug=self._debug ) as logger: logger.warning(*args, **kwargs) def error(self, *args: Any, **kwargs: Any) -> None: with LoggingContext( logger=LOGGER, quiet=self.quiet, debug=self._debug ) as logger: logger.error(*args, **kwargs) def debug(self, *args: Any, **kwargs: Any) -> None: with LoggingContext( logger=LOGGER, quiet=self.quiet, debug=self._debug ) as logger: logger.debug(*args, **kwargs) def service(self, name, *args, **kwargs): # type: ignore self.raise_not_implemented_error() def workflow(self, code, *args, **kwargs): # type: ignore self.raise_not_implemented_error() def status(self, context=None): # type: ignore self.raise_not_implemented_error() def download(self, results, targets=None): # type: ignore self.raise_not_implemented_error() def remote(self, url): # type: ignore self.raise_not_implemented_error() def robust(self, call): # type: ignore self.raise_not_implemented_error() cads-api-client-1.3.2/cads_api_client/processing.py000066400000000000000000000421561467003556600223250ustar00rootroot00000000000000from __future__ import annotations import functools import logging import os import time import urllib.parse import warnings from typing import Any, Type, TypedDict, TypeVar try: from typing import Self except ImportError: from typing_extensions import Self import attrs import multiurl import requests from . import config T_ApiResponse = TypeVar("T_ApiResponse", bound="ApiResponse") logger = logging.getLogger(__name__) class RequestKwargs(TypedDict): headers: dict[str, str] session: requests.Session retry_options: dict[str, Any] request_options: dict[str, Any] download_options: dict[str, Any] sleep_max: int cleanup: bool class ProcessingFailedError(RuntimeError): pass class DownloadError(RuntimeError): pass class LinkError(Exception): pass def error_json_to_message(error_json: dict[str, Any]) -> str: error_messages = [ str(error_json[key]) for key in ("title", "traceback", "detail") if key in error_json ] return "\n".join(error_messages) def cads_raise_for_status(response: requests.Response) -> None: if 400 <= response.status_code < 500: try: error_json = response.json() except Exception: pass else: message = "\n".join( [ f"{response.status_code} Client Error: {response.reason} for url: {response.url}", error_json_to_message(error_json), ] ) raise requests.exceptions.HTTPError(message, response=response) response.raise_for_status() def get_level_and_message(message: str) -> tuple[int, str]: level = 20 for severity in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"): if message.startswith(severity): level = logging.getLevelName(severity) message = message.replace(severity, "", 1).lstrip(":").lstrip() break return level, message @attrs.define(slots=False) class ApiResponse: response: requests.Response headers: dict[str, str] session: requests.Session retry_options: dict[str, Any] request_options: dict[str, Any] download_options: dict[str, Any] sleep_max: int cleanup: bool @property def request_kwargs(self) -> RequestKwargs: return RequestKwargs( headers=self.headers, session=self.session, retry_options=self.retry_options, request_options=self.request_options, download_options=self.download_options, sleep_max=self.sleep_max, cleanup=self.cleanup, ) @classmethod def from_request( cls: Type[T_ApiResponse], method: str, url: str, headers: dict[str, str], session: requests.Session | None, retry_options: dict[str, Any], request_options: dict[str, Any], download_options: dict[str, Any], sleep_max: int, cleanup: bool, **kwargs: Any, ) -> T_ApiResponse: if session is None: session = requests.Session() robust_request = multiurl.robust(session.request, **retry_options) inputs = kwargs.get("json", {}).get("inputs", {}) logger.debug(f"{method.upper()} {url} {inputs or ''}".strip()) response = robust_request( method, url, headers=headers, **request_options, **kwargs ) logger.debug(f"REPLY {response.text}") cads_raise_for_status(response) self = cls( response, headers=headers, session=session, retry_options=retry_options, request_options=request_options, download_options=download_options, sleep_max=sleep_max, cleanup=cleanup, ) self.log_messages() return self @property def json(self) -> dict[str, Any]: json: dict[str, Any] = self.response.json() return json def log_messages(self) -> None: if message := self.json.get("message"): level, message = get_level_and_message(message) logger.log(level, message) dataset_messages = ( self.json.get("metadata", {}).get("datasetMetadata", {}).get("messages", []) ) for dataset_message in dataset_messages: if not (content := dataset_message.get("content")): continue if date := dataset_message.get("date"): content = f"[{date}] {content}" severity = dataset_message.get("severity", "notset").upper() level = logging.getLevelName(severity) logger.log(level if isinstance(level, int) else 20, content) def get_links(self, rel: str | None = None) -> list[dict[str, str]]: links = [] for link in self.json.get("links", []): if rel is not None and link.get("rel") == rel: links.append(link) return links def get_link_href(self, rel: str | None = None) -> str: links = self.get_links(rel) if len(links) != 1: raise LinkError(f"link not found or not unique {rel=}") return links[0]["href"] def from_rel_href(self, rel: str) -> Self | None: rels = self.get_links(rel=rel) if len(rels) > 1: raise LinkError(f"link not unique {rel=}") if len(rels) == 1: out = self.from_request("get", rels[0]["href"], **self.request_kwargs) else: out = None return out @attrs.define class ProcessList(ApiResponse): def process_ids(self) -> list[str]: return [proc["id"] for proc in self.json["processes"]] def next(self) -> ApiResponse | None: return self.from_rel_href(rel="next") def prev(self) -> ApiResponse | None: return self.from_rel_href(rel="prev") @attrs.define class Process(ApiResponse): @property def id(self) -> str: process_id: str = self.json["id"] return process_id def execute(self, inputs: dict[str, Any]) -> StatusInfo: url = f"{self.response.request.url}/execution" return StatusInfo.from_request( "post", url, json={"inputs": inputs}, **self.request_kwargs ) def valid_values(self, request: dict[str, Any] = {}) -> dict[str, Any]: url = f"{self.response.request.url}/constraints" response = ApiResponse.from_request( "post", url, json={"inputs": request}, **self.request_kwargs ) response.response.raise_for_status() return response.json @attrs.define(slots=False) class Remote: url: str headers: dict[str, str] session: requests.Session retry_options: dict[str, Any] request_options: dict[str, Any] download_options: dict[str, Any] sleep_max: int cleanup: bool def __attrs_post_init__(self) -> None: self.log_start_time = None self.info(f"Request ID is {self.request_uid}") @property def request_kwargs(self) -> RequestKwargs: return RequestKwargs( headers=self.headers, session=self.session, retry_options=self.retry_options, request_options=self.request_options, download_options=self.download_options, sleep_max=self.sleep_max, cleanup=self.cleanup, ) def log_metadata(self, metadata: dict[str, Any]) -> None: logs = metadata.get("log", []) for self.log_start_time, message in sorted(logs): level, message = get_level_and_message(message) logger.log(level, message) def get_api_response(self, method: str, **kwargs: Any) -> ApiResponse: return ApiResponse.from_request( method, self.url, **self.request_kwargs, **kwargs ) @functools.cached_property def request_uid(self) -> str: return self.url.rpartition("/")[2] @property def _reply(self) -> dict[str, Any]: params = {"log": True} if self.log_start_time: params["logStartTime"] = self.log_start_time return self.get_api_response("get", params=params).json @property def status(self) -> str: reply = self._reply self.log_metadata(reply.get("metadata", {})) status: str = reply["status"] return status def wait_on_result(self) -> None: sleep = 1.0 status = None while True: if status != (status := self.status): self.info(f"status has been updated to {status}") if status == "successful": break elif status == "failed": results = self.make_results() raise ProcessingFailedError(error_json_to_message(results.json)) elif status in ("accepted", "running"): sleep *= 1.5 if sleep > self.sleep_max: sleep = self.sleep_max elif status in ("dismissed", "deleted"): raise ProcessingFailedError(f"API state {status!r}") else: raise ProcessingFailedError(f"Unknown API state {status!r}") self.debug(f"result not ready, waiting for {sleep} seconds") time.sleep(sleep) def build_status_info(self) -> StatusInfo: return StatusInfo.from_request("get", self.url, **self.request_kwargs) def make_results(self) -> Results: response = self.get_api_response("get") try: results_url = response.get_link_href(rel="results") except LinkError: results_url = f"{self.url}/results" results = Results.from_request("get", results_url, **self.request_kwargs) return results def _download_result( self, target: str | None = None, ) -> str: results = self.make_results() return results.download(target) def download( self, target: str | None = None, ) -> str: self.wait_on_result() return self._download_result(target) def delete(self) -> dict[str, Any]: response = self.get_api_response("delete") self.cleanup = False return response.json def _warn(self) -> None: message = ( ".update and .reply are available for backward compatibility." " You can now use .download directly without needing to check whether the request is completed." ) warnings.warn(message, DeprecationWarning) def update(self, request_id: str | None = None) -> None: self._warn() if request_id: assert request_id == self.request_uid try: del self.reply except AttributeError: pass self.reply @functools.cached_property def reply(self) -> dict[str, Any]: self._warn() reply = self._reply reply.setdefault("state", reply["status"]) if reply["state"] == "successful": reply["state"] = "completed" elif reply["state"] == "queued": reply["state"] = "accepted" elif reply["state"] == "failed": reply.setdefault("error", {}) try: self.make_results() except Exception as exc: reply["error"].setdefault("message", str(exc)) reply.setdefault("request_id", self.request_uid) return reply def info(self, *args: Any, **kwargs: Any) -> None: logger.info(*args, **kwargs) def warning(self, *args: Any, **kwargs: Any) -> None: logger.warning(*args, **kwargs) def error(self, *args: Any, **kwargs: Any) -> None: logger.error(*args, **kwargs) def debug(self, *args: Any, **kwargs: Any) -> None: logger.debug(*args, **kwargs) def __del__(self) -> None: if self.cleanup: try: self.delete() except Exception as exc: warnings.warn(str(exc), UserWarning) @attrs.define class StatusInfo(ApiResponse): def make_remote(self) -> Remote: if self.response.request.method == "POST": url = self.get_link_href(rel="monitor") else: url = self.get_link_href(rel="self") return Remote(url, **self.request_kwargs) @attrs.define class JobList(ApiResponse): def job_ids(self) -> list[str]: return [job["jobID"] for job in self.json["jobs"]] def next(self) -> ApiResponse | None: return self.from_rel_href(rel="next") def prev(self) -> ApiResponse | None: return self.from_rel_href(rel="prev") @attrs.define class Results(ApiResponse): @property def status_code(self) -> int: return self.response.status_code @property def reason(self) -> str: return self.response.reason def get_result_href(self) -> str: if self.status_code != 200: raise KeyError("result_href not available for processing failed results") href: str = self.json["asset"]["value"]["href"] return href @property def asset(self) -> dict[str, Any]: return dict(self.json["asset"]["value"]) @property def location(self) -> str: result_href = self.get_result_href() return urllib.parse.urljoin(self.response.url, result_href) @property def content_length(self) -> int: return int(self.asset["file:size"]) @property def content_type(self) -> str: return str(self.asset["type"]) def download( self, target: str | None = None, ) -> str: url = self.location if target is None: parts = urllib.parse.urlparse(url) target = parts.path.strip("/").split("/")[-1] download_options = {"stream": True} download_options.update(self.download_options) multiurl.download( url, target=target, **self.retry_options, **self.request_options, **download_options, ) if (target_size := os.path.getsize(target)) != (size := self.content_length): raise DownloadError( "Download failed: downloaded %s byte(s) out of %s" % (target_size, size) ) return target def info(self, *args: Any, **kwargs: Any) -> None: logger.info(*args, **kwargs) def warning(self, *args: Any, **kwargs: Any) -> None: logger.warning(*args, **kwargs) def error(self, *args: Any, **kwargs: Any) -> None: logger.error(*args, **kwargs) def debug(self, *args: Any, **kwargs: Any) -> None: logger.debug(*args, **kwargs) @attrs.define(slots=False) class Processing: url: str headers: dict[str, str] session: requests.Session retry_options: dict[str, Any] request_options: dict[str, Any] download_options: dict[str, Any] sleep_max: int cleanup: bool force_exact_url: bool = False def __attrs_post_init__(self) -> None: if not self.force_exact_url: self.url += f"/{config.SUPPORTED_API_VERSION}" @property def request_kwargs(self) -> RequestKwargs: return RequestKwargs( headers=self.headers, session=self.session, retry_options=self.retry_options, request_options=self.request_options, download_options=self.download_options, sleep_max=self.sleep_max, cleanup=self.cleanup, ) def processes(self, params: dict[str, Any] = {}) -> ProcessList: url = f"{self.url}/processes" return ProcessList.from_request( "get", url, params=params, **self.request_kwargs ) def process(self, process_id: str) -> Process: url = f"{self.url}/processes/{process_id}" return Process.from_request("get", url, **self.request_kwargs) def process_execute( self, process_id: str, inputs: dict[str, Any], ) -> StatusInfo: url = f"{self.url}/processes/{process_id}/execution" return StatusInfo.from_request( "post", url, json={"inputs": inputs}, **self.request_kwargs ) def jobs(self, params: dict[str, Any] = {}) -> JobList: url = f"{self.url}/jobs" return JobList.from_request("get", url, params=params, **self.request_kwargs) def job(self, job_id: str) -> StatusInfo: url = f"{self.url}/jobs/{job_id}" return StatusInfo.from_request("get", url, **self.request_kwargs) def job_results(self, job_id: str) -> Results: url = f"{self.url}/jobs/{job_id}/results" return Results.from_request("get", url, **self.request_kwargs) # convenience methods def submit(self, collection_id: str, **request: Any) -> Remote: status_info = self.process_execute(collection_id, request) return status_info.make_remote() def submit_and_wait_on_result(self, collection_id: str, **request: Any) -> Results: remote = self.submit(collection_id, **request) remote.wait_on_result() return remote.make_results() def make_remote(self, job_id: str) -> Remote: url = f"{self.url}/jobs/{job_id}" return Remote(url, **self.request_kwargs) def download_result(self, job_id: str, target: str | None) -> str: # NOTE: the remote waits for the result to be available return self.make_remote(job_id).download(target) cads-api-client-1.3.2/cads_api_client/profile.py000066400000000000000000000035571467003556600216130ustar00rootroot00000000000000from __future__ import annotations from typing import Any import attrs import requests from . import config, processing @attrs.define(slots=False) class Profile: url: str headers: dict[str, Any] session: requests.Session retry_options: dict[str, Any] request_options: dict[str, Any] download_options: dict[str, Any] sleep_max: int cleanup: bool force_exact_url: bool = False def __attrs_post_init__(self) -> None: if not self.force_exact_url: self.url += f"/{config.SUPPORTED_API_VERSION}" @property def request_kwargs(self) -> processing.RequestKwargs: return processing.RequestKwargs( headers=self.headers, session=self.session, retry_options=self.retry_options, request_options=self.request_options, download_options=self.download_options, sleep_max=self.sleep_max, cleanup=self.cleanup, ) def get_api_response( self, method: str, url: str, **kwargs: Any ) -> processing.ApiResponse: return processing.ApiResponse.from_request( method, url, **self.request_kwargs, **kwargs, ) def profile(self) -> dict[str, Any]: url = f"{self.url}/account" return self.get_api_response("get", url).json def accept_licence(self, licence_id: str, revision: int) -> dict[str, Any]: url = f"{self.url}/account/licences/{licence_id}" return self.get_api_response("put", url, json={"revision": revision}).json def accepted_licences(self) -> dict[str, Any]: url = f"{self.url}/account/licences" return self.get_api_response("get", url).json def check_authentication(self) -> dict[str, Any]: url = f"{self.url}/account/verification/pat" return self.get_api_response("post", url).json cads-api-client-1.3.2/cads_api_client/py.typed000066400000000000000000000000001467003556600212540ustar00rootroot00000000000000cads-api-client-1.3.2/ci/000077500000000000000000000000001467003556600150615ustar00rootroot00000000000000cads-api-client-1.3.2/ci/environment-ci.yml000066400000000000000000000005461467003556600205460ustar00rootroot00000000000000# environment-ci.yml: Additional dependencies to install in the CI environment. channels: - conda-forge - nodefaults dependencies: - make - mypy - myst-parser - pip - pre-commit - pydata-sphinx-theme - pytest - pytest-cov - sphinx - sphinx-autoapi # DO NOT EDIT ABOVE THIS LINE, ADD DEPENDENCIES BELOW - cdsapi >= 0.7.0 - types-requests - pip: - responses cads-api-client-1.3.2/ci/environment-integration.yml000066400000000000000000000004041467003556600224670ustar00rootroot00000000000000# environment-integration.yml: Additional dependencies to install in the integration environment (e.g., pinned dependencies). channels: - conda-forge - nodefaults dependencies: - make - pytest - pytest-cov # DO NOT EDIT ABOVE THIS LINE, ADD DEPENDENCIES BELOW cads-api-client-1.3.2/docs/000077500000000000000000000000001467003556600154165ustar00rootroot00000000000000cads-api-client-1.3.2/docs/Makefile000066400000000000000000000011721467003556600170570ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) cads-api-client-1.3.2/docs/_static/000077500000000000000000000000001467003556600170445ustar00rootroot00000000000000cads-api-client-1.3.2/docs/_static/.gitkeep000066400000000000000000000000001467003556600204630ustar00rootroot00000000000000cads-api-client-1.3.2/docs/_templates/000077500000000000000000000000001467003556600175535ustar00rootroot00000000000000cads-api-client-1.3.2/docs/_templates/.gitkeep000066400000000000000000000000001467003556600211720ustar00rootroot00000000000000cads-api-client-1.3.2/docs/conf.py000066400000000000000000000043551467003556600167240ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Import and path setup --------------------------------------------------- import os import sys import cads_api_client sys.path.insert(0, os.path.abspath("../")) # -- Project information ----------------------------------------------------- project = "cads_api_client" copyright = "2022, European Union" author = "European Union" version = cads_api_client.__version__ release = cads_api_client.__version__ # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "autoapi.extension", "myst_parser", "sphinx.ext.autodoc", "sphinx.ext.napoleon", ] # autodoc configuration autodoc_typehints = "none" # autoapi configuration autoapi_dirs = ["../cads_api_client"] autoapi_ignore = ["*/version.py"] autoapi_options = [ "members", "inherited-members", "undoc-members", "show-inheritance", "show-module-summary", "imported-members", ] autoapi_root = "_api" # napoleon configuration napoleon_google_docstring = False napoleon_numpy_docstring = True napoleon_preprocess_types = True # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "pydata_sphinx_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] cads-api-client-1.3.2/docs/index.md000066400000000000000000000003631467003556600170510ustar00rootroot00000000000000# Welcome to cads_api_client's documentation! CADS API Python client. ```{toctree} :caption: 'Contents:' :maxdepth: 2 API Reference <_api/cads_api_client/index> ``` # Indices and tables - {ref}`genindex` - {ref}`modindex` - {ref}`search` cads-api-client-1.3.2/docs/make.bat000066400000000000000000000013751467003556600170310ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd cads-api-client-1.3.2/environment.yml000066400000000000000000000004301467003556600175520ustar00rootroot00000000000000# environment.yml: Mandatory dependencies only. channels: - conda-forge - nodefaults # EXAMPLE: # dependencies: # - package1 # - package2 # DO NOT EDIT ABOVE THIS LINE, ADD DEPENDENCIES BELOW AS SHOWN IN THE EXAMPLE dependencies: - attrs - multiurl - requests - typing-extensions cads-api-client-1.3.2/pyproject.toml000066400000000000000000000031431467003556600174030ustar00rootroot00000000000000[build-system] build-backend = "setuptools.build_meta" requires = ["setuptools>=64", "setuptools_scm>=8"] [project] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering" ] dependencies = ["attrs", "multiurl", "requests", "typing-extensions"] description = "CADS API Python client" dynamic = ["version"] license = {file = "LICENSE"} name = "cads-api-client" readme = "README.md" [tool.coverage.run] branch = true [tool.mypy] strict = true [[tool.mypy.overrides]] ignore_missing_imports = true module = ["cdsapi.*", "multiurl.*", "py.*"] [tool.ruff] # Same as Black. indent-width = 4 line-length = 88 [tool.ruff.lint] ignore = [ # pydocstyle: Missing Docstrings "D1" ] select = [ # pyflakes "F", # pycodestyle "E", "W", # isort "I", # pydocstyle "D" ] [tool.ruff.lint.pycodestyle] max-line-length = 110 [tool.ruff.lint.pydocstyle] convention = "numpy" [tool.setuptools] packages = ["cads_api_client"] [tool.setuptools.package-data] "cads_api_client" = ["py.typed"] [tool.setuptools_scm] write_to = "cads_api_client/version.py" write_to_template = ''' # Do not change! Do not track in version control! __version__ = "{version}" ''' cads-api-client-1.3.2/tests/000077500000000000000000000000001467003556600156305ustar00rootroot00000000000000cads-api-client-1.3.2/tests/conftest.py000066400000000000000000000006101467003556600200240ustar00rootroot00000000000000from __future__ import annotations import os import pytest @pytest.fixture def api_root_url() -> str: from cads_api_client import config try: return str(config.get_config("url")) except Exception: return "http://localhost:8080/api" @pytest.fixture def api_anon_key() -> str: return os.getenv("CADS_API_ANON_KEY", "00112233-4455-6677-c899-aabbccddeeff") cads-api-client-1.3.2/tests/integration_test_10_catalogue.py000066400000000000000000000033621467003556600241140ustar00rootroot00000000000000import pytest from cads_api_client import ApiClient, catalogue @pytest.fixture def cat(api_root_url: str, monkeypatch: pytest.MonkeyPatch) -> catalogue.Catalogue: monkeypatch.setenv("CADS_API_RC", "") monkeypatch.delenv("CADS_API_KEY", raising=False) with pytest.warns(UserWarning, match="The API key is missing"): client = ApiClient(url=api_root_url, maximum_tries=0) return client.catalogue_api def test_collections(cat: catalogue.Catalogue) -> None: res: catalogue.Collections | None = cat.collections() assert isinstance(res, catalogue.Collections) assert "collections" in res.json assert isinstance(res.json["collections"], list) assert "links" in res.json assert isinstance(res.json["links"], list) collection_ids = res.collection_ids() while len(collection_ids) != res.json["numberMatched"]: res = res.next() assert res is not None collection_ids.extend(res.collection_ids()) expected_collection_id = "reanalysis-era5-single-levels" assert expected_collection_id in collection_ids def test_collections_limit(cat: catalogue.Catalogue) -> None: collections = cat.collections(params={"limit": 1}) res = collections.next() if res is not None: assert res.response.status_code == 200 def test_collection(cat: catalogue.Catalogue) -> None: collection_id = "test-adaptor-mars" res = cat.collection(collection_id) assert isinstance(res, catalogue.Collection) assert "id" in res.json assert res.id == collection_id assert "links" in res.json assert isinstance(res.json["links"], list) assert res.begin_datetime.isoformat() == "1959-01-01T00:00:00+00:00" assert res.end_datetime.isoformat() == "2023-05-09T00:00:00+00:00" cads-api-client-1.3.2/tests/integration_test_20_processing.py000066400000000000000000000047021467003556600243240ustar00rootroot00000000000000import pytest import requests from cads_api_client import ApiClient, processing @pytest.fixture def proc(api_root_url: str, api_anon_key: str) -> processing.Processing: client = ApiClient(url=api_root_url, key=api_anon_key, maximum_tries=0) return client.retrieve_api def test_processes(proc: processing.Processing) -> None: res = proc.processes() assert isinstance(res, processing.ProcessList) assert "processes" in res.json assert isinstance(res.json["processes"], list) assert "links" in res.json assert isinstance(res.json["links"], list) assert len(res.process_ids()) == 10 def test_processes_limit(proc: processing.Processing) -> None: processes = proc.processes(params={"limit": 1}) res = processes.next() if res is not None: assert res.response.status_code == 200 def test_process(proc: processing.Processing) -> None: process_id = "test-adaptor-dummy" res = proc.process(process_id) assert isinstance(res, processing.Process) assert res.id == process_id assert "links" in res.json assert isinstance(res.json["links"], list) def test_validate_constraints(proc: processing.Processing) -> None: process_id = "test-adaptor-mars" process = proc.process(process_id) res = process.valid_values({}) assert set(["product_type", "variable", "year", "month", "time"]) <= set(res) def test_collection_anonymous_user(proc: processing.Processing) -> None: collection_id = "test-adaptor-mars" process = proc.process(collection_id) response = process.execute(inputs={}) assert "message" in response.json def test_jobs_list(proc: processing.Processing) -> None: collection_id = "test-adaptor-dummy" process = proc.process(collection_id) _ = process.execute(inputs={}) _ = process.execute(inputs={}) res = proc.jobs().json assert len(res["jobs"]) >= 2 res = proc.jobs(params={"limit": 1}).json assert len(res["jobs"]) == 1 jobs = proc.jobs(params={"limit": 1}) res = jobs.next().json # type: ignore assert res is not None assert len(res["jobs"]) == 1 def test_validate_constraints_error(proc: processing.Processing) -> None: process_id = "test-adaptor-mars" process = proc.process(process_id) with pytest.raises(requests.exceptions.HTTPError, match="422 Client Error") as exc: process.valid_values({"invalid_param": 1}) assert exc.response.status_code == 422 # type: ignore[attr-defined] cads-api-client-1.3.2/tests/integration_test_30_remote.py000066400000000000000000000072361467003556600234510ustar00rootroot00000000000000import pathlib import pytest from cads_api_client import ApiClient, catalogue, processing @pytest.fixture def cat(api_root_url: str, api_anon_key: str) -> catalogue.Catalogue: client = ApiClient(url=api_root_url, key=api_anon_key, maximum_tries=0) return client.catalogue_api def test_from_collection_to_process(cat: catalogue.Catalogue) -> None: collection_id = "test-adaptor-dummy" dataset = cat.collection(collection_id) res = dataset.retrieve_process() assert isinstance(res, processing.Process) def test_collection_submit(cat: catalogue.Catalogue) -> None: collection_id = "test-adaptor-dummy" dataset = cat.collection(collection_id) res = dataset.submit() assert isinstance(res, processing.Remote) assert isinstance(res.request_uid, str) assert isinstance(res.status, str) def test_collection_retrieve_with_dummy_adaptor( cat: catalogue.Catalogue, tmp_path: pathlib.Path ) -> None: collection_id = "test-adaptor-dummy" dataset = cat.collection(collection_id) target = str(tmp_path / "dummy.txt") res = dataset.retrieve( target=target, ) assert isinstance(res, str) assert res.endswith(target) def test_collection_retrieve_with_url_cds_adaptor( cat: catalogue.Catalogue, tmp_path: pathlib.Path ) -> None: collection_id = "test-adaptor-url" dataset = cat.collection(collection_id) target = str(tmp_path / "wfde1.zip") res = dataset.retrieve( variable="grid_point_altitude", reference_dataset="cru", version="2.1", target=target, ) assert isinstance(res, str) assert res.endswith(target) target = str(tmp_path / "wfde2.zip") res = dataset.retrieve( variable="grid_point_altitude", reference_dataset="cru", version="2.1", format="zip", target=target, ) assert isinstance(res, str) assert res.endswith(target) def test_collection_retrieve_with_direct_mars_cds_adaptor( cat: catalogue.Catalogue, tmp_path: pathlib.Path ) -> None: collection_id = "test-adaptor-direct-mars" dataset = cat.collection(collection_id) target = str(tmp_path / "era5-complete.grib") request = { "levelist": "1", "dataset": "reanalysis", "time": "00:00:00", "param": "155", "date": "1940-01-01", "expect": "any", "levtype": "pl", "number": "all", "class": "ea", } res = dataset.retrieve(target=target, **request) assert isinstance(res, str) assert res.endswith(target) def test_collection_retrieve_with_mars_cds_adaptor( cat: catalogue.Catalogue, tmp_path: pathlib.Path ) -> None: collection_id = "test-adaptor-mars" dataset = cat.collection(collection_id) target = str(tmp_path / "era5.grib") res = dataset.retrieve( product_type="reanalysis", variable="2m_temperature", year="2016", month="01", day="02", time="00:00", target=target, ) assert isinstance(res, str) assert res.endswith(target) @pytest.mark.skip(reason="discontinued adaptor") def test_collection_retrieve_with_legacy_cds_adaptor( cat: catalogue.Catalogue, tmp_path: pathlib.Path ) -> None: collection_id = "test-adaptor-legacy" dataset = cat.collection(collection_id) target = str(tmp_path / "era5.grib") res = dataset.retrieve( product_type="reanalysis", variable="temperature", year="2016", month="01", day="02", time="00:00", level="1000", target=target, retry_options={"maximum_tries": 0}, ) assert isinstance(res, str) assert res.endswith(target) cads-api-client-1.3.2/tests/integration_test_40_api_client.py000066400000000000000000000102341467003556600242560ustar00rootroot00000000000000from __future__ import annotations import contextlib import datetime import os import pathlib from typing import Any import pytest import requests from urllib3.exceptions import InsecureRequestWarning from cads_api_client import ApiClient does_not_raise = contextlib.nullcontext @pytest.fixture def api_anon_client(api_root_url: str, api_anon_key: str) -> ApiClient: return ApiClient(url=api_root_url, key=api_anon_key, maximum_tries=0) def test_accept_licence() -> None: client = ApiClient(maximum_tries=0) licence = client.licences["licences"][0] licence_id = licence["id"] licence_revision = licence["revision"] expected = {"id": licence_id, "revision": licence_revision} actual = client.accept_licence(licence_id, licence_revision) assert expected == actual assert any( licence["id"] == licence_id and licence["revision"] == licence_revision for licence in client.accepted_licences["licences"] ) def test_delete_request(api_anon_client: ApiClient) -> None: remote = api_anon_client.submit( "test-adaptor-dummy", _timestamp=datetime.datetime.now().isoformat() ) reply = remote.delete() assert reply["status"] == "dismissed" with pytest.raises(requests.exceptions.HTTPError): remote.status def test_check_authentication(api_root_url: str, api_anon_client: ApiClient) -> None: assert api_anon_client.check_authentication() == { "id": -1, "role": "anonymous", "sub": "anonymous", } bad_client = ApiClient(key="foo", url=api_root_url) with pytest.raises(requests.exceptions.HTTPError, match="401 Client Error"): bad_client.check_authentication() def test_download_result(api_anon_client: ApiClient, tmp_path: pathlib.Path) -> None: remote = api_anon_client.submit("test-adaptor-dummy") target = str(tmp_path / "test.grib") result = api_anon_client.download_result(remote.request_uid, target) assert result == target assert os.path.exists(result) def test_get_remote(api_anon_client: ApiClient, tmp_path: pathlib.Path) -> None: request_uid = api_anon_client.submit("test-adaptor-dummy").request_uid result = api_anon_client.get_remote(request_uid) assert result.request_uid == request_uid def test_api_client_verify( api_root_url: str, api_anon_key: str, tmp_path: pathlib.Path, ) -> None: insecure_client = ApiClient( url=api_root_url, key=api_anon_key, verify=False, maximum_tries=0 ) with pytest.warns(InsecureRequestWarning): insecure_client.retrieve( "test-adaptor-dummy", target=str(tmp_path / "test.grib") ) def test_api_client_timeout( api_root_url: str, api_anon_key: str, tmp_path: pathlib.Path, ) -> None: client = ApiClient(url=api_root_url, key=api_anon_key, timeout=0) with pytest.raises(ValueError, match="timeout"): client.retrieve("test-adaptor-dummy", target=str(tmp_path / "test.grib")) @pytest.mark.parametrize("progress", [True, False]) def test_api_client_progress( api_root_url: str, api_anon_key: str, tmp_path: pathlib.Path, progress: bool, capsys: pytest.CaptureFixture[str], ) -> None: with capsys.disabled(): client = ApiClient( url=api_root_url, key=api_anon_key, progress=progress, maximum_tries=0 ) submitted = client.submit("test-adaptor-dummy") submitted.download(target=str(tmp_path / "test.grib")) captured = capsys.readouterr() assert captured.err if progress else not captured.err @pytest.mark.parametrize( "cleanup,raises", [ (True, pytest.raises(requests.exceptions.HTTPError, match="404 Client Error")), (False, does_not_raise()), ], ) def test_api_client_cleanup( api_root_url: str, api_anon_key: str, cleanup: bool, raises: contextlib.nullcontext[Any], ) -> None: client = ApiClient( url=api_root_url, key=api_anon_key, cleanup=cleanup, maximum_tries=0 ) remote = client.submit("test-adaptor-dummy") request_uid = remote.request_uid del remote client = ApiClient(url=api_root_url, key=api_anon_key, maximum_tries=0) with raises: client.get_request(request_uid) cads-api-client-1.3.2/tests/integration_test_50_legacy_api_client.py000066400000000000000000000127441467003556600256130ustar00rootroot00000000000000from __future__ import annotations import contextlib import pathlib import time from typing import Any import pytest import requests from cads_api_client import legacy_api_client, processing does_not_raise = contextlib.nullcontext def legacy_update(remote: processing.Remote) -> None: # See https://github.com/ecmwf/cdsapi/blob/master/examples/example-era5-update.py sleep = 1 while True: with pytest.deprecated_call(): remote.update() reply = remote.reply remote.info("Request ID: %s, state: %s" % (reply["request_id"], reply["state"])) if reply["state"] == "completed": break elif reply["state"] in ("queued", "running"): remote.info("Request ID: %s, sleep: %s", reply["request_id"], sleep) time.sleep(sleep) elif reply["state"] in ("failed",): remote.error("Message: %s", reply["error"].get("message")) remote.error("Reason: %s", reply["error"].get("reason")) for n in ( reply.get("error", {}) .get("context", {}) .get("traceback", "") .split("\n") ): if n.strip() == "": break remote.error(" %s", n) raise Exception( "%s. %s." % (reply["error"].get("message"), reply["error"].get("reason")) ) def test_retrieve(tmp_path: pathlib.Path, api_root_url: str, api_anon_key: str) -> None: client = legacy_api_client.LegacyApiClient( url=api_root_url, key=api_anon_key, retry_max=0 ) collection_id = "test-adaptor-dummy" request = {"size": 1} target = tmp_path / "test-retrieve-with-target.grib" actual_target = client.retrieve(collection_id, request, str(target)) assert str(target) == actual_target assert target.stat().st_size == 1 result = client.retrieve(collection_id, request) target = tmp_path / "test-retrieve-no-target.grib" actual_target = result.download(str(target)) assert str(target) == actual_target assert target.stat().st_size == 1 response = requests.head(result.location) assert response.status_code == 200 assert result.content_length == 1 assert result.content_type == "application/x-grib" @pytest.mark.parametrize("quiet", [True, False]) def test_quiet( caplog: pytest.LogCaptureFixture, api_root_url: str, api_anon_key: str, quiet: bool, ) -> None: client = legacy_api_client.LegacyApiClient( url=api_root_url, key=api_anon_key, quiet=quiet, retry_max=0 ) client.retrieve("test-adaptor-dummy", {}) records = [record for record in caplog.records if record.levelname == "INFO"] assert not records if quiet else records @pytest.mark.parametrize("debug", [True, False]) def test_debug( caplog: pytest.LogCaptureFixture, api_root_url: str, api_anon_key: str, debug: bool, ) -> None: legacy_api_client.LegacyApiClient( url=api_root_url, key=api_anon_key, debug=debug, retry_max=0 ) records = [record for record in caplog.records if record.levelname == "DEBUG"] assert records if debug else not records @pytest.mark.parametrize( "wait_until_complete,expected_type", [(True, processing.Results), (False, processing.Remote)], ) def test_wait_until_complete( tmp_path: pathlib.Path, api_root_url: str, api_anon_key: str, wait_until_complete: bool, expected_type: type, ) -> None: client = legacy_api_client.LegacyApiClient( url=api_root_url, key=api_anon_key, wait_until_complete=wait_until_complete, retry_max=0, ) collection_id = "test-adaptor-dummy" request = {"size": 1} result = client.retrieve(collection_id, request) assert isinstance(result, expected_type) target = tmp_path / "test.grib" result.download(str(target)) assert target.stat().st_size == 1 @pytest.mark.parametrize( "collection_id,raises", [ ("test-adaptor-dummy", does_not_raise()), ("test-adaptor-mars", pytest.raises(Exception, match="400 Client Error")), ], ) def test_legacy_update( api_root_url: str, api_anon_key: str, collection_id: str, raises: contextlib.nullcontext[Any], ) -> None: client = legacy_api_client.LegacyApiClient( url=api_root_url, key=api_anon_key, wait_until_complete=False, retry_max=0 ) remote = client.retrieve(collection_id, {}) assert isinstance(remote, processing.Remote) with raises: legacy_update(remote) def test_legacy_api_client_kwargs(api_root_url: str, api_anon_key: str) -> None: session = requests.Session() client = legacy_api_client.LegacyApiClient( url=api_root_url, key=api_anon_key, verify=False, timeout=1, progress=False, delete=True, retry_max=2, sleep_max=3, wait_until_complete=False, session=session, ) assert client.client.url == api_root_url assert client.client.key == api_anon_key assert client.client.verify is False assert client.timeout == 1 assert client.client.progress is False assert client.client.cleanup is True assert client.client.maximum_tries == 2 assert client.client.sleep_max == 3 def test_legacy_api_client_error( api_root_url: str, api_anon_key: str, ) -> None: with pytest.raises(ValueError, match="Wrong parameters: {'foo'}"): legacy_api_client.LegacyApiClient(url=api_root_url, key=api_anon_key, foo="bar") cads-api-client-1.3.2/tests/integration_test_60_features.py000066400000000000000000000034471467003556600237770ustar00rootroot00000000000000import os from pathlib import Path from typing import Any import pytest from cads_api_client import ApiClient @pytest.fixture def api_anon_client(api_root_url: str, api_anon_key: str) -> ApiClient: return ApiClient(url=api_root_url, key=api_anon_key, maximum_tries=0) def test_features_url_cds_adaptor_area_selection( tmp_path: Path, api_anon_client: ApiClient, ) -> None: collection_id = "test-adaptor-url" request: dict[str, Any] = { "variable": "grid_point_altitude", "reference_dataset": "cru", "version": "2.1", } result_bigger = api_anon_client.retrieve( collection_id, **request, target=str(tmp_path / "bigger.zip"), ) result_smaller = api_anon_client.retrieve( collection_id, **request, target=str(tmp_path / "smaller.zip"), area=[50, 0, 40, 10], ) assert os.path.getsize(result_bigger) > os.path.getsize(result_smaller) @pytest.mark.parametrize( "format,expected_extension", [ ("grib", ".grib"), ("netcdf", ".nc"), ], ) def test_features_mars_cds_adaptor_format( api_anon_client: ApiClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, format: str, expected_extension: str, ) -> None: monkeypatch.chdir(tmp_path) collection_id = "test-adaptor-mars" request: dict[str, Any] = { "product_type": "reanalysis", "variable": "2m_temperature", "year": "2016", "month": "01", "day": "02", "time": "00:00", "target": None, } result = api_anon_client.retrieve( collection_id, **request, format=format, ) _, actual_extension = os.path.splitext(result) assert actual_extension == expected_extension assert os.path.getsize(result) cads-api-client-1.3.2/tests/test_00_version.py000066400000000000000000000001451467003556600212250ustar00rootroot00000000000000import cads_api_client def test_version() -> None: assert cads_api_client.__version__ != "999" cads-api-client-1.3.2/tests/test_10_config.py000066400000000000000000000041361467003556600210120ustar00rootroot00000000000000import json import pathlib import pytest from cads_api_client import config def test_read_configuration( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: expected_config = {"url": "dummy-url", "key": "dummy-key"} config_file = tmp_path / ".cads-api-client.json" with config_file.open("w") as fp: json.dump({"url": "dummy-url", "key": "dummy-key"}, fp) res = config.read_configuration_file(str(config_file)) assert res == expected_config monkeypatch.setenv("CADS_API_RC", str(config_file)) res = config.read_configuration_file(None) assert res == expected_config def test_read_configuration_error(tmp_path: pathlib.Path) -> None: config_file = tmp_path / ".cads-api-client.json" config_file.write_text("XXX") with pytest.raises(ValueError): config.read_configuration_file(str(config_file)) with pytest.raises(FileNotFoundError): config.read_configuration_file("non-existent-file") def test_get_config_from_configuration_file(tmp_path: pathlib.Path) -> None: expected_config = {"url": "dummy-url", "key": "dummy-key"} config_file = tmp_path / ".cads-api-client.json" with config_file.open("w") as fp: json.dump(expected_config, fp) res = config.get_config("url", str(config_file)) assert res == expected_config["url"] with pytest.raises(KeyError): config.get_config("non-existent-key", str(config_file)) def test_get_config_from_environment_variables( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: expected_config = {"url": "dummy-url", "key": "dummy-key"} file_config = {"url": "wrong-url", "key": "wrong-key"} config_file = tmp_path / ".cads-api-client.json" with config_file.open("w") as fp: json.dump(file_config, fp) monkeypatch.setenv("CADS_API_URL", expected_config["url"]) monkeypatch.setenv("CADS_API_KEY", expected_config["key"]) res = config.get_config("url", str(config_file)) assert res == expected_config["url"] res = config.get_config("key", str(config_file)) assert res == expected_config["key"] cads-api-client-1.3.2/tests/test_10_processing.py000066400000000000000000000354711467003556600217270ustar00rootroot00000000000000import json import logging import pytest import requests import responses from responses.matchers import json_params_matcher import cads_api_client COLLECTION_ID = "reanalysis-era5-pressure-levels" JOB_RUNNING_ID = "9bfc1362-2832-48e1-a235-359267420bb1" JOB_SUCCESSFUL_ID = "9bfc1362-2832-48e1-a235-359267420bb2" JOB_FAILED_ID = "9bfc1362-2832-48e1-a235-359267420bb3" CATALOGUE_URL = "http://localhost:8080/api/catalogue" COLLECTIONS_URL = "http://localhost:8080/api/catalogue/v1/datasets" COLLECTION_URL = ( "http://localhost:8080/api/catalogue/v1/collections/reanalysis-era5-pressure-levels" ) PROCESS_URL = f"http://localhost:8080/api/retrieve/v1/processes/{COLLECTION_ID}" EXECUTE_URL = f"{PROCESS_URL}/execution" JOB_RUNNING_URL = f"http://localhost:8080/api/retrieve/v1/jobs/{JOB_RUNNING_ID}" JOB_SUCCESSFUL_URL = f"http://localhost:8080/api/retrieve/v1/jobs/{JOB_SUCCESSFUL_ID}" JOB_FAILED_URL = f"http://localhost:8080/api/retrieve/v1/jobs/{JOB_FAILED_ID}" RESULT_RUNNING_URL = ( f"http://localhost:8080/api/retrieve/v1/jobs/{JOB_RUNNING_ID}/results" ) RESULT_SUCCESSFUL_URL = ( f"http://localhost:8080/api/retrieve/v1/jobs/{JOB_SUCCESSFUL_ID}/results" ) RESULT_FAILED_URL = ( f"http://localhost:8080/api/retrieve/v1/jobs/{JOB_FAILED_ID}/results" ) CATALOGUE_JSON = { "type": "Catalog", "id": "stac-fastapi", "links": [ {"rel": "self", "type": "application/json", "href": f"{CATALOGUE_URL}/v1"}, {"rel": "root", "type": "application/json", "href": f"{CATALOGUE_URL}/v1"}, {"rel": "data", "type": "application/json", "href": f"{COLLECTIONS_URL}"}, { "rel": "child", "type": "application/json", "title": "ERA5 hourly data on pressure levels from 1959 to present", "href": f"{COLLECTION_URL}", }, ], } COLLECTIONS_JSON = { "collections": [ { "type": "Collection", "id": f"{COLLECTION_ID}", "links": [ { "rel": "self", "type": "application/json", "href": f"{COLLECTION_URL}", }, { "rel": "parent", "type": "application/json", "href": f"{CATALOGUE_URL}/v1", }, { "rel": "root", "type": "application/json", "href": f"{CATALOGUE_URL}/v1", }, ], }, ], "links": [ {"rel": "root", "type": "application/json", "href": f"{CATALOGUE_URL}/v1"}, {"rel": "parent", "type": "application/json", "href": f"{CATALOGUE_URL}/v1"}, {"rel": "self", "type": "application/json", "href": f"{COLLECTIONS_URL}"}, ], } COLLECTION_JSON = { "type": "Collection", "id": COLLECTION_ID, "links": [ { "rel": "self", "type": "application/json", "href": "http://localhost:8080/api/catalogue/v1/collections/reanalysis-era5-pressure-levels", }, { "rel": "parent", "type": "application/json", "href": "http://localhost:8080/api/catalogue/v1/", }, { "rel": "root", "type": "application/json", "href": "http://localhost:8080/api/catalogue/v1/", }, { "rel": "retrieve", "href": "http://localhost:8080/api/retrieve/v1/processes/reanalysis-era5-pressure-levels", "type": "application/json", }, { "rel": "related", "href": "http://localhost:8080/api/catalogue/v1/collections/reanalysis-era5-single-levels", }, ], "tmp:variables": { "Temperature": { "units": "K", } }, } PROCESS_JSON = { "id": COLLECTION_ID, "links": [ {"href": COLLECTION_URL, "rel": "self", "type": "application/json"}, {"rel": "retrieve", "href": PROCESS_URL, "type": "application/json"}, { "href": f"{COLLECTION_URL}/execution", "rel": "execute", "type": "application/json", "title": "process execution", }, ], "inputs": { "product_type": { "title": "Product type", "schema": { "type": "array", "items": {"enum": ["ensemble_mean", "reanalysis"], "type": "string"}, }, }, "variable": { "title": "Variable", "schema": { "type": "array", "items": {"enum": ["temperature", "vorticity"], "type": "string"}, }, }, "year": { "title": "Year", "schema": { "type": "array", "items": {"enum": ["2022", "0000"], "type": "string"}, }, }, }, "outputs": { "download_url": { "schema": {"type": "string", "format": "url"}, } }, "message": "WARNING: This is a warning message", "metadata": { "datasetMetadata": { "messages": [ { "date": "2023-12-12T13:00:00", "severity": "warning", "content": "This is a warning dataset message", }, { "date": "2023-12-12T14:00:00", "severity": "success", "content": "This is a success dataset message", }, ] } }, } JOB_RUNNING_JSON = { "processID": f"{COLLECTION_ID}", "type": "process", "jobID": f"{JOB_RUNNING_ID}", "status": "running", "created": "2022-09-02T17:30:48.201213", "updated": "2022-09-02T17:30:48.201217", "links": [ { "href": f"{COLLECTION_URL}/execution", "rel": "self", "type": "application/json", }, { "href": f"{JOB_RUNNING_URL}", "rel": "monitor", "type": "application/json", "title": "job status info", }, ], } JOB_SUCCESSFUL_JSON = { "processID": f"{COLLECTION_ID}", "type": "process", "jobID": f"{JOB_SUCCESSFUL_ID}", "status": "successful", "created": "2022-09-02T17:30:48.201213", "started": "2022-09-02T17:32:43.890617", "finished": "2022-09-02T17:32:54.308120", "updated": "2022-09-02T17:32:54.308116", "links": [ {"href": f"{JOB_SUCCESSFUL_ID}", "rel": "self", "type": "application/json"}, { "href": f"http://localhost:8080/api/retrieve/v1/jobs/{JOB_SUCCESSFUL_ID}/results", "rel": "results", }, { "href": f"{JOB_SUCCESSFUL_URL}", "rel": "monitor", "type": "application/json", "title": "job status info", }, ], "metadata": { "log": [ ["2024-02-09T09:14:47.811223", "This is a log"], ["2024-02-09T09:14:50.811223", "WARNING: This is a warning log"], ] }, } JOB_FAILED_JSON = { "processID": f"{COLLECTION_ID}", "type": "process", "jobID": f"{JOB_FAILED_ID}", "status": "failed", "created": "2022-09-02T17:30:48.201213", "started": "2022-09-02T17:32:43.890617", "finished": "2022-09-02T17:32:54.308120", "updated": "2022-09-02T17:32:54.308116", "links": [ {"href": f"{JOB_FAILED_ID}", "rel": "self", "type": "application/json"}, { "href": f"http://localhost:8080/api/retrieve/v1/jobs/{JOB_FAILED_ID}/results", "rel": "results", }, { "href": f"{JOB_FAILED_URL}", "rel": "monitor", "type": "application/json", "title": "job status info", }, ], "metadata": { "log": [ ["2024-02-09T09:14:47.811223", "This is a log"], ["2024-02-09T09:14:50.811223", "WARNING: This is a warning log"], ] }, } RESULT_SUCCESSFUL_JSON = { "asset": { "value": { "type": "application/netcdf", "href": "./e7d452a747061ab880887d88814bfb0c27593a73cb7736d2dc340852", "file:checksum": "e7d452a747061ab880887d88814bfb0c27593a73cb7736d2dc340852", "file:size": 8, "file:local_path": [ "/cache-store/", "e7d452a747061ab880887d88814bfb0c27593a73cb7736d2dc340852.nc", ], "xarray:open_kwargs": {}, "xarray:storage_options": {}, } } } RESULT_RUNNING_JSON = { "type": "http://www.opengis.net/def/exceptions/ogcapi-processes-1/1.0/result-not-ready", "title": "job results not ready", "detail": "job 8b7a1f3d-04b1-425d-96f1-f0634d02ee7f results are not yet ready", "instance": "http://127.0.0.1:8080/api/retrieve/v1/jobs/8b7a1f3d-04b1-425d-96f1-f0634d02ee7f/results", } RESULT_FAILED_JSON = { "type": "job results failed", "title": "job failed", "status": 400, "instance": "http://127.0.0.1:8080/api/retrieve/v1/jobs/02135eee-39a8-4d1f-8cd7-87682de5b981/results", "trace_id": "ca3e7170-1ce2-48fc-97f8-bbe64fafce44", "traceback": "This is a traceback", } @pytest.fixture def catalogue() -> cads_api_client.Catalogue: return cads_api_client.Catalogue( CATALOGUE_URL, headers={}, session=requests.Session(), retry_options={}, request_options={}, download_options={}, sleep_max=120, cleanup=False, ) def responses_add() -> None: responses.add( responses.GET, url=f"{CATALOGUE_URL}/v1/", json=CATALOGUE_JSON, content_type="application/json", ) responses.add( responses.GET, url=f"{COLLECTIONS_URL}", json=COLLECTIONS_JSON, content_type="application/json", ) responses.add( responses.GET, url=f"{COLLECTION_URL}", json=COLLECTION_JSON, content_type="application/json", ) responses.add( responses.GET, url=PROCESS_URL, json=PROCESS_JSON, content_type="application/json", ) responses.add( responses.POST, url=EXECUTE_URL, json=JOB_SUCCESSFUL_JSON, match=[ json_params_matcher( { "inputs": {"variable": "temperature", "year": "2022"}, } ) ], content_type="application/json", ) responses.add( responses.GET, url=JOB_SUCCESSFUL_URL, json=JOB_SUCCESSFUL_JSON, content_type="application/json", ) responses.add( responses.POST, url=EXECUTE_URL, json=JOB_FAILED_JSON, match=[ json_params_matcher( { "inputs": {"variable": "temperature", "year": "0000"}, } ) ], content_type="application/json", ) responses.add( responses.GET, url=JOB_FAILED_URL, json=JOB_FAILED_JSON, content_type="application/json", ) responses.add( responses.GET, url=RESULT_FAILED_URL, json=RESULT_FAILED_JSON, content_type="application/json", ) @responses.activate def test_catalogue_collections(catalogue: cads_api_client.Catalogue) -> None: responses_add() collections = catalogue.collections() assert collections.response.json() == COLLECTIONS_JSON collection = catalogue.collection(COLLECTION_ID) assert collection.response.json() == COLLECTION_JSON @responses.activate def test_submit(catalogue: cads_api_client.Catalogue) -> None: responses_add() collection = catalogue.collection(COLLECTION_ID) process = collection.retrieve_process() assert process.response.json() == PROCESS_JSON job = process.execute(inputs={"variable": "temperature", "year": "2022"}) assert job.response.json() == JOB_SUCCESSFUL_JSON remote = collection.submit(variable="temperature", year="2022") assert remote.url == JOB_SUCCESSFUL_URL assert remote.status == "successful" @responses.activate def test_wait_on_result(catalogue: cads_api_client.Catalogue) -> None: responses_add() collection = catalogue.collection(COLLECTION_ID) remote = collection.submit(variable="temperature", year="2022") remote.wait_on_result() @responses.activate def test_wait_on_result_failed(catalogue: cads_api_client.Catalogue) -> None: responses_add() collection = catalogue.collection(COLLECTION_ID) remote = collection.submit(variable="temperature", year="0000") with pytest.raises( cads_api_client.processing.ProcessingFailedError, match="job failed\nThis is a traceback", ): remote.wait_on_result() @responses.activate def test_remote_logs( caplog: pytest.LogCaptureFixture, catalogue: cads_api_client.Catalogue ) -> None: responses_add() collection = catalogue.collection(COLLECTION_ID) with caplog.at_level(logging.DEBUG, logger="cads_api_client.processing"): remote = collection.submit(variable="temperature", year="2022") remote.wait_on_result() assert caplog.record_tuples == [ ( "cads_api_client.processing", 10, "GET http://localhost:8080/api/retrieve/v1/processes/reanalysis-era5-pressure-levels", ), ( "cads_api_client.processing", 10, f"REPLY {json.dumps(PROCESS_JSON)}", ), ( "cads_api_client.processing", 30, "This is a warning message", ), ( "cads_api_client.processing", 30, "[2023-12-12T13:00:00] This is a warning dataset message", ), ( "cads_api_client.processing", 20, "[2023-12-12T14:00:00] This is a success dataset message", ), ( "cads_api_client.processing", 10, ( "POST http://localhost:8080/api/retrieve/v1/processes/" "reanalysis-era5-pressure-levels/execution " "{'variable': 'temperature', 'year': '2022'}" ), ), ( "cads_api_client.processing", 10, f"REPLY {json.dumps(JOB_SUCCESSFUL_JSON)}", ), ( "cads_api_client.processing", 20, "Request ID is 9bfc1362-2832-48e1-a235-359267420bb2", ), ( "cads_api_client.processing", 10, "GET http://localhost:8080/api/retrieve/v1/jobs/9bfc1362-2832-48e1-a235-359267420bb2", ), ( "cads_api_client.processing", 10, f"REPLY {json.dumps(JOB_SUCCESSFUL_JSON)}", ), ("cads_api_client.processing", 20, "This is a log"), ("cads_api_client.processing", 30, "This is a warning log"), ("cads_api_client.processing", 20, "status has been updated to successful"), ]