├── .github └── workflows │ ├── build.yml │ └── deploy.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── cnct ├── __init__.py └── rql.py ├── connect └── client │ ├── __init__.py │ ├── constants.py │ ├── contrib │ ├── __init__.py │ └── locust │ │ ├── __init__.py │ │ └── user.py │ ├── exceptions.py │ ├── fluent.py │ ├── help_formatter.py │ ├── logger.py │ ├── mixins.py │ ├── models │ ├── __init__.py │ ├── base.py │ ├── exceptions.py │ ├── iterators.py │ ├── mixins.py │ └── resourceset.py │ ├── openapi.py │ ├── rql │ ├── __init__.py │ ├── base.py │ └── utils.py │ ├── testing │ ├── __init__.py │ ├── fixtures.py │ ├── fluent.py │ └── models │ │ ├── __init__.py │ │ ├── base.py │ │ ├── mixins.py │ │ └── resourceset.py │ ├── utils.py │ └── version.py ├── docs ├── async_client.md ├── css │ └── custom.css ├── favicon.ico ├── getting_started.md ├── index.md ├── logo.png ├── logo_full.png ├── namespaces_and_collections.md ├── querying_collections.md ├── r_object.md ├── sync_client.md ├── testing.md └── working_with_resources.md ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── requirements └── docs.txt ├── sonar-project.properties └── tests ├── __init__.py ├── async_client ├── __init__.py ├── test_fixtures.py ├── test_fluent.py ├── test_models.py └── test_testing.py ├── client ├── __init__.py ├── test_exceptions.py ├── test_fixtures.py ├── test_fluent.py ├── test_help_formatter.py ├── test_logger.py ├── test_models.py ├── test_moves.py ├── test_openapi.py ├── test_testing.py ├── test_utils.py └── test_version.py ├── conftest.py ├── data └── specs.yml ├── fixtures ├── __init__.py └── client_models.py └── rql ├── __init__.py ├── test_base.py └── test_utils.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Connect Python OpenAPI Client 2 | 3 | on: 4 | push: 5 | branches: '*' 6 | tags: 7 | - '*' 8 | pull_request: 9 | branches: [ master ] 10 | 11 | jobs: 12 | build: 13 | name: Build and test on Python ${{ matrix.python-version }} 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ['3.9', '3.10', '3.11', '3.12'] 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install poetry 31 | poetry install 32 | - name: Linting 33 | run: | 34 | poetry run flake8 35 | - name: Testing 36 | run: | 37 | poetry run pytest 38 | sonar: 39 | name: Sonar Checks 40 | needs: build 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v3 45 | with: 46 | fetch-depth: 0 47 | - name: Set up Python '3.10.0' 48 | uses: actions/setup-python@v4 49 | with: 50 | python-version: '3.10' 51 | - name: Install dependencies 52 | run: | 53 | python -m pip install --upgrade pip 54 | pip install poetry 55 | poetry install --no-root 56 | - name: Testing 57 | run: | 58 | poetry run pytest 59 | - name: Fix coverage.xml for Sonar 60 | run: | 61 | sed -i 's/\/home\/runner\/work\/connect-python-openapi-client\/connect-python-openapi-client\//\/github\/workspace\//g' coverage.xml 62 | - name: SonarCloud 63 | uses: SonarSource/sonarcloud-github-action@master 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 67 | - name: Wait sonar to process report 68 | uses: jakejarvis/wait-action@master 69 | with: 70 | time: '15s' 71 | - name: SonarQube Quality Gate check 72 | uses: sonarsource/sonarqube-quality-gate-action@master 73 | timeout-minutes: 5 74 | env: 75 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Connect Python OpenAPI Client 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.10' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install poetry 20 | poetry install --no-root 21 | - name: Linting 22 | run: | 23 | poetry run flake8 24 | - name: Testing 25 | run: | 26 | poetry run pytest 27 | - name: Extract tag name 28 | uses: actions/github-script@v6 29 | id: tag 30 | with: 31 | github-token: ${{ secrets.GITHUB_TOKEN }} 32 | result-encoding: string 33 | script: | 34 | return context.payload.ref.replace(/refs\/tags\//, '') 35 | - name: Build and publish 36 | env: 37 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 38 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 39 | run: | 40 | poetry version ${{ steps.tag.outputs.result }} 41 | poetry build 42 | poetry publish -u $TWINE_USERNAME -p $TWINE_PASSWORD 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .pytest_cache 3 | *.py[cod] 4 | venv*/ 5 | 6 | build/ 7 | dist/ 8 | *.egg-info 9 | .eggs 10 | 11 | .idea 12 | .vscode 13 | .devcontainer 14 | 15 | coverage/ 16 | .coverage 17 | /htmlcov/ 18 | docs/_build 19 | temp/ 20 | coverage.xml 21 | *.DS_Store 22 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Required 2 | version: 2 3 | 4 | build: 5 | os: ubuntu-20.04 6 | tools: 7 | python: "3.9" 8 | 9 | mkdocs: 10 | configuration: mkdocs.yml 11 | 12 | python: 13 | install: 14 | - requirements: requirements/docs.txt 15 | - method: pip 16 | path: . 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Connect Python OpenAPI Client 2 | 3 | ![pyversions](https://img.shields.io/pypi/pyversions/connect-openapi-client.svg) [![PyPi Status](https://img.shields.io/pypi/v/connect-openapi-client.svg)](https://pypi.org/project/connect-openapi-client/) [![Build Status](https://github.com/cloudblue/connect-python-openapi-client/workflows/Build%20Connect%20Python%20OpenAPI%20Client/badge.svg)](https://github.com/cloudblue/connect-python-openapi-client/actions) [![codecov](https://codecov.io/gh/cloudblue/connect-python-openapi-client/branch/master/graph/badge.svg)](https://codecov.io/gh/cloudblue/connect-python-openapi-client) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=connect-open-api-client&metric=alert_status)](https://sonarcloud.io/dashboard?id=connect-open-api-client) 4 | 5 | 6 | 7 | 8 | ## Introduction 9 | 10 | `Connect Python OpenAPI Client` is the simple, concise, powerful and REPL-friendly CloudBlue Connect API client. 11 | 12 | It has been designed following the [fluent interface design pattern](https://en.wikipedia.org/wiki/Fluent_interface). 13 | 14 | Due to its REPL-friendly nature, using the CloudBlue Connect OpenAPI specifications it allows developers to learn and 15 | play with the CloudBlue Connect API using a python REPL like [jupyter](https://jupyter.org/) or [ipython](https://ipython.org/). 16 | 17 | 18 | ## Install 19 | 20 | `Connect Python OpenAPI Client` requires python 3.9 or later. 21 | 22 | 23 | `Connect Python OpenAPI Client` can be installed from [pypi.org](https://pypi.org/project/connect-openapi-client/) using pip: 24 | 25 | ``` 26 | $ pip install connect-openapi-client 27 | ``` 28 | 29 | 30 | ## Development 31 | We use `isort` library to order and format our imports, and `black` - to format the code. 32 | We check it using `flake8-isort` and `flake8-black` libraries (automatically on `flake8` run). 33 | For convenience you may run `isort . && black .` to format the code. 34 | 35 | 36 | ## Documentation 37 | 38 | [`Connect Python OpenAPI Client` documentation](https://connect-openapi-client.readthedocs.io/en/latest/) is hosted on the _Read the Docs_ service. 39 | 40 | 41 | ## License 42 | 43 | ``Connect Python OpenAPI Client`` is released under the [Apache License Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). 44 | -------------------------------------------------------------------------------- /cnct/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.exceptions import ClientError # noqa 7 | from connect.client.fluent import ConnectClient # noqa 8 | from connect.client.rql import R # noqa 9 | -------------------------------------------------------------------------------- /cnct/rql.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.rql import R # noqa 7 | -------------------------------------------------------------------------------- /connect/client/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.exceptions import ClientError # noqa 7 | from connect.client.fluent import AsyncConnectClient, ConnectClient # noqa 8 | from connect.client.logger import RequestLogger # noqa 9 | from connect.client.rql import R # noqa 10 | -------------------------------------------------------------------------------- /connect/client/constants.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | CONNECT_SPECS_URL = 'https://apispec.connect.cloudblue.com/connect-openapi30.yml' # noqa 7 | CONNECT_ENDPOINT_URL = 'https://api.connect.cloudblue.com/public/v1' # noqa 8 | -------------------------------------------------------------------------------- /connect/client/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudblue/connect-python-openapi-client/dbbb0385baadb52e85d8d504b2c29d934ad577eb/connect/client/contrib/__init__.py -------------------------------------------------------------------------------- /connect/client/contrib/locust/__init__.py: -------------------------------------------------------------------------------- 1 | from locust import events 2 | 3 | from connect.client.contrib.locust.user import ConnectHttpUser # noqa 4 | 5 | 6 | @events.init_command_line_parser.add_listener 7 | def _(parser): 8 | parser.add_argument('--connect-api-key', default='', help='Connect Api Key') 9 | -------------------------------------------------------------------------------- /connect/client/contrib/locust/user.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from locust import HttpUser 4 | from requests import RequestException 5 | 6 | from connect.client import ConnectClient 7 | 8 | 9 | class _LocustConnectClient(ConnectClient): 10 | def __init__(self, base_url, request_event, user, *args, **kwargs): 11 | self.base_url = base_url 12 | self.request_event = request_event 13 | self.user = user 14 | 15 | super().__init__( 16 | kwargs['connect_api_key'], 17 | self.base_url, 18 | use_specs=False, 19 | max_retries=0, 20 | timeout=(5, 120), 21 | ) 22 | 23 | def _execute_http_call(self, method, url, kwargs): 24 | start_time = time.perf_counter() 25 | exc = None 26 | try: 27 | super()._execute_http_call(method, url, kwargs) 28 | except RequestException as e: 29 | exc = e 30 | 31 | response = self.response.history and self.response.history[0] or self.response 32 | request_meta = { 33 | 'request_type': method, 34 | 'response_time': (time.perf_counter() - start_time) * 1000, 35 | 'name': response.request.path_url, 36 | 'context': {}, 37 | 'response': self.response, 38 | 'exception': exc, 39 | 'response_length': len(self.response.content or b''), 40 | } 41 | 42 | self.request_event.fire(**request_meta) 43 | 44 | 45 | class ConnectHttpUser(HttpUser): 46 | abstract = True 47 | 48 | def __init__(self, *args, **kwargs): 49 | super().__init__(*args, **kwargs) 50 | 51 | self.client = _LocustConnectClient( 52 | self.host, 53 | request_event=self.environment.events.request, 54 | user=self, 55 | connect_api_key=self.environment.parsed_options.connect_api_key, 56 | ) 57 | -------------------------------------------------------------------------------- /connect/client/exceptions.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from http import HTTPStatus 7 | 8 | 9 | class ClientError(Exception): 10 | def __init__(self, message=None, status_code=None, error_code=None, errors=None, **kwargs): 11 | self.message = message 12 | self.status_code = status_code 13 | self.error_code = error_code 14 | self.errors = errors 15 | self.additional_info = kwargs 16 | 17 | def __repr__(self): 18 | return f'' 19 | 20 | def __str__(self): 21 | message = self.message or self._get_status_description() or 'Unexpected error' 22 | if self.error_code and self.errors: 23 | errors = ','.join(self.errors) 24 | return f'{message}: {self.error_code} - {errors}' 25 | return message 26 | 27 | def _get_status_description(self): 28 | if not self.status_code: 29 | return 30 | status = HTTPStatus(self.status_code) 31 | description = status.name.replace('_', ' ').title() 32 | return f'{self.status_code} {description}' 33 | -------------------------------------------------------------------------------- /connect/client/fluent.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | import contextvars 7 | import threading 8 | from functools import cache 9 | from json.decoder import JSONDecodeError 10 | from typing import Union 11 | 12 | import httpx 13 | import requests 14 | from httpx._config import Proxy 15 | from httpx._utils import get_environment_proxies 16 | from requests.adapters import HTTPAdapter 17 | 18 | from connect.client.constants import CONNECT_ENDPOINT_URL, CONNECT_SPECS_URL 19 | from connect.client.help_formatter import DefaultFormatter 20 | from connect.client.mixins import AsyncClientMixin, SyncClientMixin 21 | from connect.client.models import ( 22 | NS, 23 | AsyncCollection, 24 | AsyncNS, 25 | Collection, 26 | ) 27 | from connect.client.openapi import OpenAPISpecs 28 | from connect.client.utils import get_headers 29 | 30 | 31 | _SYNC_TRANSPORTS = {} 32 | _ASYNC_TRANSPORTS = {} 33 | 34 | 35 | class _ConnectClientBase: 36 | def __init__( 37 | self, 38 | api_key, 39 | endpoint=None, 40 | use_specs=False, 41 | specs_location=None, 42 | validate_using_specs=True, 43 | default_headers=None, 44 | default_limit=100, 45 | max_retries=3, 46 | logger=None, 47 | timeout=(15.0, 180.0), 48 | resourceset_append=True, 49 | ): 50 | if default_headers and 'Authorization' in default_headers: 51 | raise ValueError('`default_headers` cannot contains `Authorization`') 52 | 53 | self.endpoint = endpoint or CONNECT_ENDPOINT_URL 54 | self.api_key = api_key 55 | self.default_headers = default_headers or {} 56 | self.default_limit = default_limit 57 | self.max_retries = max_retries 58 | self._use_specs = use_specs 59 | self._validate_using_specs = validate_using_specs 60 | self.specs_location = specs_location or CONNECT_SPECS_URL 61 | self.specs = None 62 | if self._use_specs: 63 | self.specs = OpenAPISpecs(self.specs_location) 64 | self.logger = logger 65 | self._help_formatter = DefaultFormatter(self.specs) 66 | self.timeout = timeout 67 | self.resourceset_append = resourceset_append 68 | 69 | def __getattr__(self, name): 70 | if name in ('session', 'response'): 71 | return self.__getattribute__(name) 72 | if '_' in name: 73 | name = name.replace('_', '-') 74 | return self.collection(name) 75 | 76 | def __call__(self, name): 77 | return self.ns(name) 78 | 79 | def ns(self, name: str) -> Union[NS, AsyncNS]: 80 | """ 81 | Returns a `Namespace` object identified by its name. 82 | 83 | Usage: 84 | 85 | ```py3 86 | subscriptions = client.ns('subscriptions') 87 | ``` 88 | 89 | Concise form: 90 | 91 | ```py3 92 | subscriptions = client('subscriptions') 93 | ``` 94 | 95 | Args: 96 | name (str): The name of the namespace to access. 97 | """ 98 | if not isinstance(name, str): 99 | raise TypeError('`name` must be a string.') 100 | 101 | if not name: 102 | raise ValueError('`name` must not be blank.') 103 | 104 | return self._get_namespace_class()(self, name) 105 | 106 | def collection(self, name: str) -> Union[Collection, AsyncCollection]: 107 | """ 108 | Returns a `Collection` object identified by its name. 109 | 110 | Usage: 111 | 112 | ```py3 113 | products = client.collection('products') 114 | ``` 115 | 116 | Concise form: 117 | 118 | ```py3 119 | products = client.products 120 | ``` 121 | 122 | Args: 123 | name (str): The name of the collection to access. 124 | """ 125 | if not isinstance(name, str): 126 | raise TypeError('`name` must be a string.') 127 | 128 | if not name: 129 | raise ValueError('`name` must not be blank.') 130 | 131 | return self._get_collection_class()( 132 | self, 133 | name, 134 | ) 135 | 136 | def print_help(self, obj): 137 | print() 138 | print(self._help_formatter.format(obj)) 139 | 140 | def help(self): 141 | self.print_help(None) 142 | return self 143 | 144 | def _get_collection_class(self): 145 | raise NotImplementedError() 146 | 147 | def _get_namespace_class(self): 148 | raise NotImplementedError() 149 | 150 | def _prepare_call_kwargs(self, kwargs): 151 | kwargs = kwargs or {} 152 | if 'headers' in kwargs: 153 | kwargs['headers'].update(get_headers(self.api_key)) 154 | else: 155 | kwargs['headers'] = get_headers(self.api_key) 156 | if 'timeout' not in kwargs: 157 | kwargs['timeout'] = self.timeout 158 | if self.default_headers: 159 | kwargs['headers'].update(self.default_headers) 160 | return kwargs 161 | 162 | def _get_api_error_details(self): 163 | if self.response is not None: 164 | try: 165 | error = self.response.json() 166 | if 'error_code' in error and 'errors' in error: 167 | return error 168 | except JSONDecodeError: 169 | pass 170 | 171 | 172 | class ConnectClient(_ConnectClientBase, SyncClientMixin): 173 | """ 174 | Create a new instance of the ConnectClient. 175 | 176 | Usage: 177 | 178 | ```py3 179 | from connect.client import ConnectClient 180 | client = ConnectClient('ApiKey SU-000-000-000:xxxxxxxxxxxxxxxx') 181 | product = client.products['PRD-001-002-003'].get() 182 | ``` 183 | 184 | Args: 185 | api_key (str): The API key used for authentication. 186 | endpoint (str): (Optional) The API endpoint, defaults to 187 | https://api.connect.cloudblue.com/public/v1. 188 | use_specs (bool): (Optional) Use Connect OpenAPI specifications. 189 | specs_location (str): (Optional) The Connect OpenAPI specification local path or URL. 190 | validate_using_specs (bool): (Optional) Use the Connect OpenAPI specification to validate 191 | the call. 192 | default_headers (dict): (Optional) HTTP headers to apply to each request. 193 | default_limit (int): (Optional) Default value for pagination limit parameter. 194 | max_retries (int): (Optional) Max number of retries for a request before raising an error. 195 | logger: (Optional) HTTP Request logger class. 196 | timeout (int): (Optional) Timeout parameter to pass to the underlying HTTP client. 197 | resourceset_append: (Optional) Append all the pages to the current resourceset. 198 | """ 199 | 200 | def __init__(self, *args, **kwargs): 201 | super().__init__(*args, **kwargs) 202 | self._thread_locals = threading.local() 203 | 204 | @property 205 | def session(self): 206 | if not hasattr(self._thread_locals, 'session'): 207 | self._thread_locals.session = requests.Session() 208 | self._thread_locals.session.mount( 209 | self.endpoint, 210 | _SYNC_TRANSPORTS.setdefault( 211 | self.endpoint, 212 | HTTPAdapter(), 213 | ), 214 | ) 215 | 216 | return self._thread_locals.session 217 | 218 | @property 219 | def response(self) -> requests.Response: 220 | """ 221 | Returns the raw 222 | [`requests`](https://requests.readthedocs.io/en/latest/api/#requests.Response) 223 | response. 224 | """ 225 | if not hasattr(self._thread_locals, 'response'): 226 | self._thread_locals.response = None 227 | return self._thread_locals.response 228 | 229 | @response.setter 230 | def response(self, value: requests.Response): 231 | self._thread_locals.response = value 232 | 233 | def _get_collection_class(self): 234 | return Collection 235 | 236 | def _get_namespace_class(self): 237 | return NS 238 | 239 | 240 | _SSL_CONTEXT = httpx.create_ssl_context() 241 | 242 | 243 | @cache 244 | def _get_async_mounts(): 245 | """ 246 | This code based on how httpx.Client mounts proxies from environment. 247 | This is cached to allow reusing the created transport objects. 248 | """ 249 | return { 250 | key: None 251 | if url is None 252 | else httpx.AsyncHTTPTransport(verify=_SSL_CONTEXT, proxy=Proxy(url=url)) 253 | for key, url in get_environment_proxies().items() 254 | } 255 | 256 | 257 | class AsyncConnectClient(_ConnectClientBase, AsyncClientMixin): 258 | """ 259 | Create a new instance of the AsyncConnectClient. 260 | 261 | Usage: 262 | 263 | ```py3 264 | from connect.client import AsyncConnectClient 265 | client = AsyncConnectClient('ApiKey SU-000-000-000:xxxxxxxxxxxxxxxx') 266 | product = await client.products['PRD-001-002-003'].get() 267 | ``` 268 | 269 | Args: 270 | api_key (str): The API key used for authentication. 271 | endpoint (str): (Optional) The API endpoint, defaults to 272 | https://api.connect.cloudblue.com/public/v1. 273 | use_specs (bool): (Optional) Use Connect OpenAPI specifications. 274 | specs_location: (Optional) The Connect OpenAPI specification local path or URL. 275 | validate_using_specs: (Optional) Use the Connect OpenAPI specification to validate 276 | the call. 277 | default_headers (dict): (Optional) HTTP headers to apply to each request. 278 | default_limit (int): (Optional) Default value for pagination limit parameter. 279 | max_retries (int): (Optional) Max number of retries for a request before raising an error. 280 | logger: (Optional) HTTP Request logger class. 281 | timeout (int): (Optional) Timeout parameter to pass to the underlying HTTP client. 282 | resourceset_append: (Optional) Append all the pages to the current resourceset. 283 | """ 284 | 285 | def __init__(self, *args, **kwargs): 286 | super().__init__(*args, **kwargs) 287 | self._response = contextvars.ContextVar('response', default=None) 288 | self._session = contextvars.ContextVar('session', default=None) 289 | 290 | @property 291 | def session(self): 292 | value = self._session.get() 293 | if not value: 294 | transport = _ASYNC_TRANSPORTS.get(self.endpoint) 295 | if not transport: 296 | transport = _ASYNC_TRANSPORTS[self.endpoint] = httpx.AsyncHTTPTransport( 297 | verify=_SSL_CONTEXT, 298 | ) 299 | # When passing a transport to httpx a Client/AsyncClient, proxies defined in environment 300 | # (like HTTP_PROXY) are ignored, so let's pass them using mounts parameter. 301 | value = httpx.AsyncClient(transport=transport, mounts=_get_async_mounts()) 302 | self._session.set(value) 303 | return value 304 | 305 | @property 306 | def response(self): 307 | """ 308 | Returns the raw 309 | [`httpx`](https://www.python-httpx.org/api/#response) 310 | response. 311 | """ 312 | return self._response.get() 313 | 314 | @response.setter 315 | def response(self, value): 316 | self._response.set(value) 317 | 318 | def _get_collection_class(self): 319 | return AsyncCollection 320 | 321 | def _get_namespace_class(self): 322 | return AsyncNS 323 | -------------------------------------------------------------------------------- /connect/client/help_formatter.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | import inflect 7 | from connect.utils.terminal.markdown import render 8 | 9 | from connect.client.models import ( 10 | NS, 11 | Action, 12 | AsyncAction, 13 | AsyncCollection, 14 | AsyncNS, 15 | AsyncResource, 16 | AsyncResourceSet, 17 | Collection, 18 | Resource, 19 | ResourceSet, 20 | ) 21 | 22 | 23 | _COL_HTTP_METHOD_TO_METHOD = { 24 | 'get': '.all(), .filter(), .first(), .last()', 25 | 'post': '.create()', 26 | } 27 | 28 | 29 | class DefaultFormatter: 30 | def __init__(self, specs): 31 | self._specs = specs 32 | self._p = inflect.engine() 33 | 34 | def format_client(self): 35 | lines = [ 36 | f'# Welcome to {self._specs.title} {self._specs.version}', 37 | '## Introduction', 38 | ] + self._specs.description.splitlines() 39 | 40 | lines += [ 41 | '', 42 | '## Namespaces', 43 | ] 44 | for ns in self._specs.get_namespaces(): 45 | lines.append(f'* {ns}') 46 | 47 | lines += [ 48 | '', 49 | '## Collections', 50 | ] 51 | for col in self._specs.get_collections(): 52 | lines.append(f'* {col}') 53 | 54 | return render('\n'.join(lines)) 55 | 56 | def format_ns(self, ns): 57 | namespaces = self._specs.get_nested_namespaces(ns.path) 58 | collections = self._specs.get_namespaced_collections(ns.path) 59 | 60 | if not (collections or namespaces): 61 | return render(f'~~{ns.path}~~ **does not exists.**') 62 | 63 | lines = [ 64 | f'# {ns.path.title()} namespace', 65 | f'**path: /{ns.path}**', 66 | ] 67 | if namespaces: 68 | lines.append('## Available namespaces') 69 | for namespace in namespaces: 70 | lines.append(f'* {namespace}') 71 | 72 | if collections: 73 | lines.append('## Available collections') 74 | for collection in collections: 75 | lines.append(f'* {collection}') 76 | 77 | return render('\n'.join(lines)) 78 | 79 | def format_collection(self, collection): 80 | col_info = self._specs.get_collection(collection.path) 81 | if not col_info: 82 | return render(f'~~{collection.path}~~ **does not exists.**') 83 | 84 | if '/' in collection.path: 85 | _, collection_name = collection.path.rsplit('/', 1) 86 | else: 87 | collection_name = collection.path 88 | 89 | lines = [ 90 | f'# {collection_name.title()} collection', 91 | f'**path: /{collection.path}**', 92 | ] 93 | 94 | lines.extend(self._format_heading(col_info)) 95 | lines.append('### Available operations') 96 | if 'get' in col_info: 97 | lines.append(f'* GET: {_COL_HTTP_METHOD_TO_METHOD["get"]}') 98 | 99 | if 'post' in col_info: 100 | lines.append(f'* POST: {_COL_HTTP_METHOD_TO_METHOD["post"]}') 101 | 102 | return render('\n'.join(lines)) 103 | 104 | def format_resource(self, resource): 105 | res_info = self._specs.get_resource(resource.path) 106 | if not res_info: 107 | return render(f'~~{resource.path}~~ **does not exists.**') 108 | 109 | resource_name = resource.path.split('/')[-2] 110 | resource_name = self._p.singular_noun(resource_name) 111 | lines = [ 112 | f'# {resource_name.title()} resource', 113 | f'**path: /{resource.path}**', 114 | ] 115 | 116 | lines.extend(self._format_heading(res_info)) 117 | 118 | actions = self._specs.get_actions(resource.path) 119 | if actions: 120 | lines.append('### Available actions') 121 | for name, summary in actions: 122 | if summary: 123 | lines.append(f'* {name} - {summary}') 124 | else: 125 | lines.append(f'* {name}') 126 | lines.append('') 127 | 128 | nested = self._specs.get_nested_collections(resource.path) 129 | if nested: 130 | lines.append('### Available nested collections') 131 | for name, summary in nested: 132 | if summary: 133 | lines.append(f'* {name} - {summary}') 134 | else: 135 | lines.append(f'* {name}') 136 | 137 | return render('\n'.join(lines)) 138 | 139 | def format_action(self, action): 140 | action_info = self._specs.get_action(action.path) 141 | if not action_info: 142 | return render(f'~~{action.path}~~ **does not exists.**') 143 | 144 | _, action_name = action.path.rsplit('/', 1) 145 | 146 | lines = [ 147 | f'# {action_name.title()} action', 148 | f'**path: /{action.path}**', 149 | ] 150 | lines.extend(self._format_heading(action_info)) 151 | lines.append('## Available methods') 152 | for method in ('get', 'post', 'put', 'delete'): 153 | if method in action_info: 154 | lines.append(f'* {method.upper()}: .{method}()') 155 | 156 | return render('\n'.join(lines)) 157 | 158 | def format_resource_set(self, rs): 159 | col_info = self._specs.get_collection(rs.path) 160 | if not col_info: 161 | return render(f'~~{rs.path}~~ **does not exists.**') 162 | 163 | if '/' in rs.path: 164 | _, collection_name = rs.path.rsplit('/', 1) 165 | else: 166 | collection_name = rs.path 167 | 168 | lines = [ 169 | f'# Search the {collection_name.title()} collection', 170 | f'**path: /{rs.path}**', 171 | ] 172 | lines.extend(self._format_heading(col_info)) 173 | get_info = col_info['get'] 174 | params = get_info.get('parameters') 175 | if not params: 176 | return render('\n'.join(lines)) 177 | filters = list(sorted(filter(lambda x: '$ref' not in x, params), key=lambda x: x['name'])) 178 | lines.append(f'Support pagination: *{len(params) > len(filters)}*') 179 | if not filters: 180 | return render('\n'.join(lines)) 181 | 182 | lines.append('## Available filters') 183 | for filter_ in filters: 184 | lines.append(f'*{filter_["name"]}*') 185 | description = filter_['description'].splitlines() 186 | for line in description: 187 | line = line.strip() 188 | if line: 189 | lines.append(f' {line}') 190 | lines.append('') 191 | 192 | return render('\n'.join(lines)) 193 | 194 | def format(self, obj): 195 | if not self._specs: 196 | return render('**No OpenAPI specs available.**') 197 | 198 | if isinstance(obj, (NS, AsyncNS)): 199 | return self.format_ns(obj) 200 | 201 | if isinstance(obj, (Collection, AsyncCollection)): 202 | return self.format_collection(obj) 203 | 204 | if isinstance(obj, (Resource, AsyncResource)): 205 | return self.format_resource(obj) 206 | 207 | if isinstance(obj, (Action, AsyncAction)): 208 | return self.format_action(obj) 209 | 210 | if isinstance(obj, (ResourceSet, AsyncResourceSet)): 211 | return self.format_resource_set(obj) 212 | 213 | return self.format_client() 214 | 215 | def _format_heading(self, info): 216 | lines = [] 217 | for section in ('summary', 'description'): 218 | if section in info: 219 | lines.append(f'### {section.title()}') 220 | lines.append(info[section]) 221 | return lines 222 | -------------------------------------------------------------------------------- /connect/client/logger.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | 5 | class RequestLogger: 6 | def __init__(self, file=sys.stdout): 7 | self._file = file 8 | 9 | def obfuscate(self, key: str, value: str) -> str: 10 | if key in ('authorization', 'authentication'): 11 | if value.startswith('ApiKey '): 12 | return value.split(':')[0] + '*' * 10 13 | else: 14 | return '*' * 20 15 | if key in ('cookie', 'set-cookie'): 16 | if 'api_key="' in value: 17 | start_idx = value.index('api_key="') + len('api_key="') 18 | end_idx = value.index('"', start_idx) 19 | return f'{value[0:start_idx + 2]}******{value[end_idx - 2:]}' 20 | return value 21 | 22 | def log_request(self, method: str, url: str, kwargs): 23 | other_args = {k: v for k, v in kwargs.items() if k not in ('headers', 'json', 'params')} 24 | 25 | if 'params' in kwargs: 26 | url += '&' if '?' in url else '?' 27 | url += '&'.join([f'{k}={v}' for k, v in kwargs['params'].items()]) 28 | 29 | lines = [ 30 | '--- HTTP Request ---', 31 | f'{method.upper()} {url} {other_args if other_args else ""}', 32 | ] 33 | 34 | if 'headers' in kwargs: 35 | for k, v in kwargs['headers'].items(): 36 | if k.lower() in ('authorization', 'authentication', 'cookie'): 37 | v = self.obfuscate(k.lower(), v) 38 | lines.append(f'{k}: {v}') 39 | 40 | if 'json' in kwargs: 41 | lines.append(json.dumps(kwargs['json'], indent=4)) 42 | 43 | lines.append('') 44 | 45 | print(*lines, sep='\n', file=self._file) 46 | 47 | def log_response(self, response): 48 | reason = response.raw.reason if getattr(response, 'raw', None) else response.reason_phrase 49 | lines = [ 50 | '--- HTTP Response ---', 51 | f'{response.status_code} {reason}', 52 | ] 53 | 54 | for k, v in response.headers.items(): 55 | if k.lower() == 'set-cookie': 56 | v = self.obfuscate(k.lower(), v) 57 | lines.append(f'{k}: {v}') 58 | 59 | if response.headers.get('Content-Type', None) == 'application/json': 60 | lines.append(json.dumps(response.json(), indent=4)) 61 | 62 | lines.append('') 63 | 64 | print(*lines, sep='\n', file=self._file) 65 | -------------------------------------------------------------------------------- /connect/client/mixins.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | import time 7 | from typing import Any, Dict 8 | 9 | from httpx import HTTPError 10 | from requests.exceptions import RequestException, Timeout 11 | 12 | from connect.client.exceptions import ClientError 13 | 14 | 15 | class SyncClientMixin: 16 | def get(self, url: str, **kwargs) -> Any: 17 | """ 18 | Make a GET call to the given url. 19 | 20 | Args: 21 | url (str): The url to make the call. 22 | """ 23 | return self.execute('get', url, **kwargs) 24 | 25 | def create(self, url: str, payload: Dict = None, **kwargs) -> Any: 26 | """ 27 | Make a POST call to the given url with the payload. 28 | 29 | Args: 30 | url (str): The url to make the call. 31 | payload (dict): (Optional) The payload to be used. 32 | """ 33 | kwargs = kwargs or {} 34 | 35 | if payload: 36 | kwargs['json'] = payload 37 | 38 | return self.execute('post', url, **kwargs) 39 | 40 | def update(self, url: str, payload: Dict = None, **kwargs) -> Any: 41 | """ 42 | Make a PUT call to the given url with the payload. 43 | 44 | Args: 45 | url (str): The url to make the call. 46 | payload (dict): (Optional) The payload to be used. 47 | """ 48 | kwargs = kwargs or {} 49 | 50 | if payload: 51 | kwargs['json'] = payload 52 | 53 | return self.execute('put', url, **kwargs) 54 | 55 | def delete(self, url: str, payload: Dict = None, **kwargs) -> Any: 56 | """ 57 | Make a DELETE call to the given url with the payload. 58 | 59 | Args: 60 | url (str): The url to make the call. 61 | payload (dict): (Optional) The payload to be used. 62 | """ 63 | kwargs = kwargs or {} 64 | 65 | if payload: 66 | kwargs['json'] = payload 67 | 68 | return self.execute('delete', url, **kwargs) 69 | 70 | def execute(self, method: str, path: str, **kwargs) -> Any: 71 | if self._use_specs and self._validate_using_specs and not self.specs.exists(method, path): 72 | # TODO more info, specs version, method etc 73 | raise ClientError(f'The path `{path}` does not exist.') 74 | 75 | url = f'{self.endpoint}/{path}' 76 | 77 | kwargs = self._prepare_call_kwargs(kwargs) 78 | 79 | self.response = None 80 | 81 | try: 82 | self._execute_http_call(method, url, kwargs) 83 | 84 | if self.response.status_code == 204: 85 | return None 86 | if self.response.headers.get('Content-Type', '').startswith('application/json'): 87 | return self.response.json() 88 | else: 89 | return self.response.content 90 | 91 | except RequestException as re: 92 | api_error = self._get_api_error_details() or {} 93 | status_code = self.response.status_code if self.response is not None else None 94 | raise ClientError(status_code=status_code, **api_error) from re 95 | 96 | def _execute_http_call(self, method, url, kwargs): # noqa: CCR001 97 | retry_count = 0 98 | while True: 99 | if self.logger: 100 | self.logger.log_request(method, url, kwargs) 101 | try: 102 | self.response = self.session.request(method, url, **kwargs) 103 | if self.logger: 104 | self.logger.log_response(self.response) 105 | except RequestException: 106 | if retry_count < self.max_retries: 107 | retry_count += 1 108 | time.sleep(1) 109 | continue 110 | raise 111 | 112 | if ( # pragma: no branch 113 | self.response.status_code >= 500 and retry_count < self.max_retries 114 | ): 115 | retry_count += 1 116 | time.sleep(1) 117 | continue 118 | break # pragma: no cover 119 | if self.response.status_code >= 400: 120 | self.response.raise_for_status() 121 | 122 | 123 | class AsyncClientMixin: 124 | async def get(self, url: str, **kwargs) -> Any: 125 | """ 126 | Make a GET call to the given url. 127 | 128 | Args: 129 | url (str): The url to make the call. 130 | """ 131 | return await self.execute('get', url, **kwargs) 132 | 133 | async def create(self, url: str, payload: Dict = None, **kwargs) -> Any: 134 | """ 135 | Make a POST call to the given url with the payload. 136 | 137 | Args: 138 | url (str): The url to make the call. 139 | payload (dict): (Optional) The payload to be used. 140 | """ 141 | kwargs = kwargs or {} 142 | 143 | if payload: 144 | kwargs['json'] = payload 145 | 146 | return await self.execute('post', url, **kwargs) 147 | 148 | async def update(self, url: str, payload: Dict = None, **kwargs) -> Any: 149 | """ 150 | Make a PUT call to the given url with the payload. 151 | 152 | Args: 153 | url (str): The url to make the call. 154 | payload (dict): (Optional) The payload to be used. 155 | """ 156 | kwargs = kwargs or {} 157 | 158 | if payload: 159 | kwargs['json'] = payload 160 | 161 | return await self.execute('put', url, **kwargs) 162 | 163 | async def delete(self, url: str, payload: Dict = None, **kwargs) -> Any: 164 | """ 165 | Make a DELETE call to the given url with the payload. 166 | 167 | Args: 168 | url (str): The url to make the call. 169 | payload (dict): (Optional) The payload to be used. 170 | """ 171 | kwargs = kwargs or {} 172 | 173 | if payload: 174 | kwargs['json'] = payload 175 | 176 | return await self.execute('delete', url, **kwargs) 177 | 178 | async def execute(self, method: str, path: str, **kwargs) -> Any: 179 | if self._use_specs and self._validate_using_specs and not self.specs.exists(method, path): 180 | # TODO more info, specs version, method etc 181 | raise ClientError(f'The path `{path}` does not exist.') 182 | 183 | url = f'{self.endpoint}/{path}' 184 | 185 | kwargs = self._prepare_call_kwargs(kwargs) 186 | 187 | url, kwargs = self._fix_url_params(url, kwargs) 188 | 189 | self.response = None 190 | 191 | try: 192 | await self._execute_http_call(method, url, kwargs) 193 | if self.response.status_code == 204: 194 | return None 195 | if self.response.headers.get('Content-Type', '').startswith('application/json'): 196 | return self.response.json() 197 | else: 198 | return self.response.content 199 | 200 | except HTTPError as re: 201 | api_error = self._get_api_error_details() or {} 202 | status_code = self.response.status_code if self.response is not None else None 203 | raise ClientError(status_code=status_code, **api_error) from re 204 | 205 | async def _execute_http_call(self, method, url, kwargs): 206 | retry_count = 0 207 | while True: 208 | if self.logger: 209 | self.logger.log_request(method, url, kwargs) 210 | 211 | try: 212 | self.response = await self.session.request(method, url, **kwargs) 213 | 214 | if self.logger: 215 | self.logger.log_response(self.response) 216 | except HTTPError: 217 | if retry_count < self.max_retries: 218 | retry_count += 1 219 | time.sleep(1) 220 | continue 221 | raise 222 | if ( # pragma: no branch 223 | self.response.status_code >= 500 and retry_count < self.max_retries 224 | ): 225 | retry_count += 1 226 | time.sleep(1) 227 | continue 228 | break # pragma: no cover 229 | 230 | if self.response.status_code >= 400: 231 | self.response.raise_for_status() 232 | 233 | def _fix_url_params(self, url, kwargs): 234 | if 'params' in kwargs: 235 | params = kwargs.pop('params') 236 | qs_fragment = '&'.join([f'{k}={v}' for k, v in params.items()]) 237 | join = '?' if '?' not in url else '&' 238 | if url.endswith('?'): 239 | join = '' 240 | url = f'{url}{join}{qs_fragment}' 241 | return url, kwargs 242 | -------------------------------------------------------------------------------- /connect/client/models/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.models.base import ( # noqa 7 | NS, 8 | Action, 9 | AsyncAction, 10 | AsyncCollection, 11 | AsyncNS, 12 | AsyncResource, 13 | Collection, 14 | Resource, 15 | ) 16 | from connect.client.models.exceptions import NotYetEvaluatedError # noqa 17 | from connect.client.models.resourceset import AsyncResourceSet, ResourceSet # noqa 18 | -------------------------------------------------------------------------------- /connect/client/models/base.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.models.mixins import ( 7 | ActionMixin, 8 | AsyncActionMixin, 9 | AsyncCollectionMixin, 10 | AsyncResourceMixin, 11 | CollectionMixin, 12 | ResourceMixin, 13 | ) 14 | from connect.client.models.resourceset import AsyncResourceSet, ResourceSet 15 | from connect.client.rql import R 16 | 17 | 18 | class _NSBase: 19 | def __init__(self, client, path): 20 | self._client = client 21 | self._path = path 22 | 23 | @property 24 | def path(self) -> str: 25 | return self._path 26 | 27 | def __getattr__(self, name): 28 | if '_' in name: 29 | name = name.replace('_', '-') 30 | return self.collection(name) 31 | 32 | def __iter__(self): 33 | raise TypeError('A Namespace object is not iterable.') 34 | 35 | def __call__(self, name): 36 | return self.ns(name) 37 | 38 | def collection(self, name: str): 39 | """ 40 | Returns a `[Async]Collection` object nested under this namespace object 41 | identified by its name. 42 | 43 | Usage: 44 | 45 | ```py3 46 | devops_ns = client.ns('devops') 47 | services = devops_ns.collection('products') 48 | ``` 49 | 50 | Concise form: 51 | 52 | ```py3 53 | services = client('devops').services 54 | ``` 55 | 56 | Args: 57 | name (str): The name of the collection to access. 58 | 59 | Returns: 60 | (Union[Collection, AsyncCollection]): Returns an object nested under this namespace 61 | object identified by its name. 62 | """ 63 | 64 | if not isinstance(name, str): 65 | raise TypeError('`name` must be a string.') 66 | 67 | if not name: 68 | raise ValueError('`name` must not be blank.') 69 | 70 | return self._get_collection_class()( 71 | self._client, 72 | f'{self._path}/{name}', 73 | ) 74 | 75 | def ns(self, name: str): 76 | """ 77 | Returns a `[Async]Namespace` object nested under this namespace 78 | identified by its name. 79 | 80 | Usage: 81 | 82 | ```py3 83 | subscriptions_ns = client.ns('subscriptions') 84 | nested_ns = subcriptions_ns.ns('nested') 85 | ``` 86 | 87 | Concise form: 88 | 89 | ```py3 90 | nested_ns = client('subscriptions')('nested') 91 | ``` 92 | 93 | Args: 94 | name (str): The name of the namespace to access. 95 | 96 | Returns: 97 | (Union[NS, AsyncNS]): Returns an object nested under this namespace 98 | identified by its name. 99 | """ 100 | if not isinstance(name, str): 101 | raise TypeError('`name` must be a string.') 102 | 103 | if not name: 104 | raise ValueError('`name` must not be blank.') 105 | 106 | return self._get_namespace_class()( 107 | self._client, 108 | f'{self._path}/{name}', 109 | ) 110 | 111 | def help(self): 112 | self._client.print_help(self) 113 | return self 114 | 115 | def _get_collection_class(self): 116 | raise NotImplementedError() 117 | 118 | def _get_namespace_class(self): 119 | raise NotImplementedError() 120 | 121 | 122 | class NS(_NSBase): 123 | def _get_collection_class(self): 124 | return Collection 125 | 126 | def _get_namespace_class(self): 127 | return NS 128 | 129 | 130 | class AsyncNS(_NSBase): 131 | def _get_collection_class(self): 132 | return AsyncCollection 133 | 134 | def _get_namespace_class(self): 135 | return AsyncNS 136 | 137 | 138 | class _CollectionBase: 139 | def __init__(self, client, path): 140 | self._client = client 141 | self._path = path 142 | 143 | @property 144 | def path(self): 145 | return self._path 146 | 147 | def __iter__(self): 148 | raise TypeError('A Collection object is not iterable.') 149 | 150 | def __getitem__(self, resource_id): 151 | return self.resource(resource_id) 152 | 153 | def __call__(self, name): 154 | return self.action(name) 155 | 156 | def all(self): 157 | """ 158 | Returns a `[Async]ResourceSet` object that that allow to access all the resources that 159 | belong to this collection. 160 | 161 | Returns: 162 | (Union[ResourceSet, AsyncResourceSet]): Returns an object that that allow to access 163 | all the resources that belong to this collection. 164 | """ 165 | return self._get_resourceset_class()( 166 | self._client, 167 | self._path, 168 | ) 169 | 170 | def filter(self, *args, **kwargs): 171 | """ 172 | Returns a `[Async]ResourceSet` object. 173 | The returned ResourceSet object will be filtered based on 174 | the arguments and keyword arguments. 175 | 176 | Arguments can be RQL filter expressions as strings 177 | or R objects. 178 | 179 | Usage: 180 | 181 | ```py3 182 | rs = collection.filter('eq(field,value)', 'eq(another.field,value2)') 183 | rs = collection.filter(R().field.eq('value'), R().another.field.eq('value2')) 184 | ``` 185 | 186 | All the arguments will be combined with logical **and**. 187 | 188 | Filters can be also specified as keyword argument using the **__** (double underscore) 189 | notation. 190 | 191 | Usage: 192 | 193 | ```py3 194 | rs = collection.filter( 195 | field=value, 196 | another__field=value, 197 | field2__in=('a', 'b'), 198 | field3__null=True, 199 | ) 200 | ``` 201 | 202 | Also keyword arguments will be combined with logical **and**. 203 | 204 | Returns: 205 | (Union[ResourceSet, AsyncResourceSet]): The returned ResourceSet object will be 206 | filtered based on the arguments and keyword arguments. 207 | """ 208 | query = R() 209 | for arg in args: 210 | if isinstance(arg, str): 211 | query &= R(_expr=arg) 212 | continue 213 | if isinstance(arg, R): 214 | query &= arg 215 | continue 216 | raise TypeError(f'arguments must be string or R not {type(arg)}') 217 | 218 | if kwargs: 219 | query &= R(**kwargs) 220 | 221 | return self._get_resourceset_class()( 222 | self._client, 223 | self._path, 224 | query=query, 225 | ) 226 | 227 | def resource(self, resource_id: str): 228 | """ 229 | Returns a `[Async]Resource` object that represent a resource that belong to 230 | this collection identified by its unique identifier. 231 | 232 | Usage: 233 | 234 | ```py3 235 | resource = client.collection('products').resource('PRD-000-111-222') 236 | ``` 237 | 238 | Concise form: 239 | 240 | ```py3 241 | resource = client.products['PRD-000-111-222'] 242 | ``` 243 | 244 | Args: 245 | resource_id (str): The unique identifier of the resource. 246 | 247 | Returns: 248 | (Union[Resource, AsyncResource]): Returns an object that represent a resource that 249 | belong to this collection identified by its unique identifier. 250 | """ 251 | if not isinstance(resource_id, (str, int)): 252 | raise TypeError('`resource_id` must be a string or int.') 253 | 254 | if not resource_id: 255 | raise ValueError('`resource_id` must not be blank.') 256 | 257 | return self._get_resource_class()( 258 | self._client, 259 | f'{self._path}/{resource_id}', 260 | ) 261 | 262 | def action(self, name: str): 263 | """ 264 | Returns an `[Async]Action` object that represent an action to perform 265 | on this collection identified by its name. 266 | 267 | Args: 268 | name (str): The name of the action to perform. 269 | 270 | Returns: 271 | (Union[Action, AsyncAction]): Returns an object that represent an action to perform 272 | on this collection identified by its name. 273 | """ 274 | if not isinstance(name, str): 275 | raise TypeError('`name` must be a string.') 276 | 277 | if not name: 278 | raise ValueError('`name` must not be blank.') 279 | 280 | return self._get_action_class()( 281 | self._client, 282 | f'{self._path}/{name}', 283 | ) 284 | 285 | def help(self): 286 | self._client.print_help(self) 287 | return self 288 | 289 | def _get_resource_class(self): 290 | return NotImplementedError() # pragma: no cover 291 | 292 | def _get_resourceset_class(self): 293 | return NotImplementedError() # pragma: no cover 294 | 295 | def _get_action_class(self): 296 | raise NotImplementedError() # pragma: no cover 297 | 298 | 299 | class Collection(_CollectionBase, CollectionMixin): 300 | def _get_resource_class(self): 301 | return Resource 302 | 303 | def _get_resourceset_class(self): 304 | return ResourceSet 305 | 306 | def _get_action_class(self): 307 | return Action 308 | 309 | 310 | class AsyncCollection(_CollectionBase, AsyncCollectionMixin): 311 | def _get_resource_class(self): 312 | return AsyncResource 313 | 314 | def _get_resourceset_class(self): 315 | return AsyncResourceSet 316 | 317 | def _get_action_class(self): 318 | return AsyncAction 319 | 320 | 321 | class _ResourceBase: 322 | def __init__(self, client, path): 323 | self._client = client 324 | self._path = path 325 | 326 | @property 327 | def path(self): 328 | return self._path 329 | 330 | def __getattr__(self, name): 331 | if '_' in name: 332 | name = name.replace('_', '-') 333 | return self.collection(name) 334 | 335 | def __call__(self, name): 336 | return self.action(name) 337 | 338 | def collection(self, name: str): 339 | """ 340 | Returns a `[Async]Collection` object nested under this resource object 341 | identified by its name. 342 | 343 | Usage: 344 | 345 | ```py3 346 | environments = ( 347 | client.ns("devops") 348 | .collection("services") 349 | .resource("SRVC-0000-1111") 350 | .collection("environments") 351 | ) 352 | ``` 353 | 354 | Concise form: 355 | 356 | ```py3 357 | services = client('devops').services['SRVC-0000-1111'].environments 358 | ``` 359 | 360 | Args: 361 | name (str): The name of the collection to access. 362 | 363 | Returns: 364 | (Union[Collection, AsyncCollection]): Returns an object nested under this resource 365 | object identified by its name. 366 | """ # noqa: E501 367 | if not isinstance(name, str): 368 | raise TypeError('`name` must be a string.') 369 | 370 | if not name: 371 | raise ValueError('`name` must not be blank.') 372 | 373 | return self._get_collection_class()( 374 | self._client, 375 | f'{self._path}/{name}', 376 | ) 377 | 378 | def action(self, name: str): 379 | """ 380 | Returns an `[Async]Action` object that can be performed on this this resource object 381 | identified by its name. 382 | 383 | Usage: 384 | 385 | ```py3 386 | approve_action = ( 387 | client.collection('requests') 388 | .resource('PR-000-111-222') 389 | .action('approve') 390 | ) 391 | ``` 392 | 393 | Concise form: 394 | 395 | ```py3 396 | approve_action = client.requests[''PR-000-111-222']('approve') 397 | ``` 398 | 399 | Args: 400 | name (str): The name of the action to perform. 401 | 402 | Returns: 403 | (Union[Action, AsyncAction]): Returns an object that can be performed on this this 404 | resource object identified by its name. 405 | """ 406 | if not isinstance(name, str): 407 | raise TypeError('`name` must be a string.') 408 | 409 | if not name: 410 | raise ValueError('`name` must not be blank.') 411 | 412 | return self._get_action_class()( 413 | self._client, 414 | f'{self._path}/{name}', 415 | ) 416 | 417 | def help(self): 418 | self._client.print_help(self) 419 | return self 420 | 421 | def _get_collection_class(self): 422 | raise NotImplementedError() 423 | 424 | def _get_action_class(self): 425 | raise NotImplementedError() 426 | 427 | 428 | class Resource(_ResourceBase, ResourceMixin): 429 | def _get_collection_class(self): 430 | return Collection 431 | 432 | def _get_action_class(self): 433 | return Action 434 | 435 | 436 | class AsyncResource(_ResourceBase, AsyncResourceMixin): 437 | def _get_collection_class(self): 438 | return AsyncCollection 439 | 440 | def _get_action_class(self): 441 | return AsyncAction 442 | 443 | 444 | class _ActionBase: 445 | def __init__(self, client, path): 446 | self._client = client 447 | self._path = path 448 | 449 | @property 450 | def path(self): 451 | return self._path 452 | 453 | def help(self): 454 | self._client.print_help(self) 455 | return self 456 | 457 | 458 | class Action(_ActionBase, ActionMixin): 459 | pass 460 | 461 | 462 | class AsyncAction(_ActionBase, AsyncActionMixin): 463 | pass 464 | -------------------------------------------------------------------------------- /connect/client/models/exceptions.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | class NotYetEvaluatedError(Exception): 7 | pass 8 | -------------------------------------------------------------------------------- /connect/client/models/iterators.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.utils import get_values, parse_content_range 7 | 8 | 9 | class aiter: 10 | def __init__(self, values): 11 | self._values = iter(values) 12 | 13 | async def __anext__(self): 14 | try: 15 | return next(self._values) 16 | except StopIteration: 17 | raise StopAsyncIteration 18 | 19 | 20 | class AbstractBaseIterator: 21 | def __init__(self, rs, client, path, query, config, **kwargs): 22 | self._rs = rs 23 | self._results_iterator = None 24 | self._client = client 25 | self._path = path 26 | self._query = query 27 | self._config = config 28 | self._kwargs = kwargs 29 | self._loaded = False 30 | 31 | def get_item(self, item): 32 | raise NotImplementedError('get_item must be implemented in subclasses.') 33 | 34 | 35 | class AbstractIterator(AbstractBaseIterator): 36 | def _load(self): 37 | if not self._loaded: 38 | self._rs._results, self._rs._content_range = self._execute_request() 39 | self._results_iterator = iter(self._rs._results) 40 | self._loaded = True 41 | 42 | def __next__(self): # noqa: CCR001 43 | self._load() 44 | 45 | if not self._rs._results: 46 | raise StopIteration 47 | try: 48 | item = next(self._results_iterator) 49 | except StopIteration: 50 | if ( 51 | self._rs._content_range is None 52 | or self._rs._content_range.last >= self._rs._content_range.count - 1 53 | or (self._rs._slice and self._rs._content_range.last >= self._rs._slice.stop - 1) 54 | ): 55 | raise 56 | self._config['params']['offset'] += self._config['params']['limit'] 57 | if self._rs._slice: 58 | items_to_fetch = self._rs._slice.stop - self._rs._content_range.last - 1 59 | if items_to_fetch < self._config['params']['limit']: 60 | self._config['params']['limit'] = items_to_fetch 61 | results, cr = self._execute_request() 62 | if not results: 63 | raise 64 | self._rs._content_range = cr 65 | if self._client.resourceset_append: 66 | self._rs._results.extend(results) 67 | else: 68 | self._rs._results = results 69 | self._results_iterator = iter(results) 70 | item = next(self._results_iterator) 71 | 72 | return self.get_item(item) 73 | 74 | def _execute_request(self): 75 | results = self._client.get( 76 | f'{self._path}?{self._query}', 77 | **self._config, 78 | ) 79 | content_range = parse_content_range( 80 | self._client.response.headers.get('Content-Range'), 81 | ) 82 | return results, content_range 83 | 84 | 85 | class AbstractAsyncIterator(AbstractBaseIterator): 86 | async def _load(self): 87 | if not self._loaded: 88 | self._rs._results, self._rs._content_range = await self._execute_request() 89 | self._results_iterator = iter(self._rs._results) 90 | self._loaded = True 91 | 92 | async def __anext__(self): # noqa: CCR001 93 | await self._load() 94 | 95 | if not self._rs._results: 96 | raise StopAsyncIteration 97 | try: 98 | item = next(self._results_iterator) 99 | except StopIteration: 100 | if ( 101 | self._rs._content_range is None 102 | or self._rs._content_range.last >= self._rs._content_range.count - 1 103 | or (self._rs._slice and self._rs._content_range.last >= self._rs._slice.stop - 1) 104 | ): 105 | raise StopAsyncIteration 106 | self._config['params']['offset'] += self._config['params']['limit'] 107 | if self._rs._slice: 108 | items_to_fetch = self._rs._slice.stop - self._rs._content_range.last - 1 109 | if items_to_fetch < self._config['params']['limit']: 110 | self._config['params']['limit'] = items_to_fetch 111 | results, cr = await self._execute_request() 112 | if not results: 113 | raise StopAsyncIteration 114 | self._rs._content_range = cr 115 | if self._client.resourceset_append: 116 | self._rs._results.extend(results) 117 | else: 118 | self._rs._results = results 119 | self._results_iterator = iter(results) 120 | item = next(self._results_iterator) 121 | 122 | return self.get_item(item) 123 | 124 | async def _execute_request(self): 125 | results = await self._client.get( 126 | f'{self._path}?{self._query}', 127 | **self._config, 128 | ) 129 | content_range = parse_content_range( 130 | self._client.response.headers.get('Content-Range'), 131 | ) 132 | return results, content_range 133 | 134 | 135 | class ResourceMixin: 136 | def get_item(self, item): 137 | return item 138 | 139 | 140 | class ValueListMixin: 141 | def get_item(self, item): 142 | return get_values(item, self._kwargs['fields']) 143 | 144 | 145 | class ResourceIterator(ResourceMixin, AbstractIterator): 146 | pass 147 | 148 | 149 | class ValuesListIterator(ValueListMixin, AbstractIterator): 150 | pass 151 | 152 | 153 | class AsyncResourceIterator(ResourceMixin, AbstractAsyncIterator): 154 | pass 155 | 156 | 157 | class AsyncValuesListIterator(ValueListMixin, AbstractAsyncIterator): 158 | pass 159 | -------------------------------------------------------------------------------- /connect/client/models/resourceset.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | import copy 7 | 8 | from connect.client.models.exceptions import NotYetEvaluatedError 9 | from connect.client.models.iterators import ( 10 | AsyncResourceIterator, 11 | AsyncValuesListIterator, 12 | ResourceIterator, 13 | ValuesListIterator, 14 | aiter, 15 | ) 16 | from connect.client.rql import R 17 | from connect.client.utils import parse_content_range, resolve_attribute 18 | 19 | 20 | class _ResourceSetBase: 21 | def __init__( 22 | self, 23 | client, 24 | path, 25 | query=None, 26 | ): 27 | self._client = client 28 | self._path = path 29 | self._query = query or R() 30 | self._results = None 31 | self._limit = self._client.default_limit or 100 32 | self._offset = 0 33 | self._slice = None 34 | self._content_range = None 35 | self._fields = None 36 | self._search = None 37 | self._select = [] 38 | self._ordering = [] 39 | self._config = {} 40 | 41 | @property 42 | def path(self): 43 | return self._path 44 | 45 | @property 46 | def query(self): 47 | return self._query 48 | 49 | @property 50 | def content_range(self): 51 | return self._content_range 52 | 53 | def configure(self, **kwargs): 54 | """ 55 | Set the default keyword arguments that must be provided to the 56 | underlying GET call on each page fetch. 57 | """ 58 | copy = self._copy() 59 | copy._config = kwargs or {} 60 | return copy 61 | 62 | def limit(self, limit: int): 63 | """ 64 | Set the number of results that must be fetched on each 65 | HTTP call. 66 | 67 | Args: 68 | limit (int): Number of results to fetch in each HTTP call. 69 | 70 | Returns: 71 | (ResourceSet): Returns a copy of the current ResourceSet with the limit applied. 72 | """ 73 | if not isinstance(limit, int): 74 | raise TypeError('`limit` must be an integer.') 75 | 76 | if limit <= 0: 77 | raise ValueError('`limit` must be a positive, non-zero integer.') 78 | 79 | copy = self._copy() 80 | copy._limit = limit 81 | return copy 82 | 83 | def order_by(self, *fields): 84 | """ 85 | Add fields for ordering. 86 | 87 | Usage: 88 | 89 | ```py3 90 | purchases = client.requests.all().order_by( 91 | 'asset.tiers.customer.name', 92 | '-created' 93 | ) 94 | ``` 95 | !!! note 96 | To sort results in descending order the name of the field must 97 | be prefixed with a `-` (minus) sign. 98 | 99 | Returns: 100 | (ResourceSet): Returns a copy of the current ResourceSet with the order applied. 101 | """ 102 | copy = self._copy() 103 | copy._ordering.extend(fields) 104 | return copy 105 | 106 | def select(self, *fields): 107 | """ 108 | Apply the RQL ``select`` operator to 109 | this ResourceSet object. 110 | 111 | Usage: 112 | 113 | ```py3 114 | purchases = client.requests.all().select( 115 | '-asset.items', 116 | '-asset.params', 117 | 'activation_key', 118 | ) 119 | ``` 120 | !!! note 121 | To unselect a field it must 122 | be prefixed with a `-` (minus) sign. 123 | 124 | Returns: 125 | (ResourceSet): Returns a copy of the current ResourceSet with the select applied. 126 | """ 127 | copy = self._copy() 128 | copy._select.extend(fields) 129 | return copy 130 | 131 | def filter(self, *args, **kwargs): 132 | """ 133 | Applies filters to this ResourceSet object. 134 | 135 | Arguments can be RQL filter expressions as strings 136 | or R objects. 137 | 138 | Usage: 139 | 140 | ```py3 141 | rs = rs.filter('eq(field,value)', 'eq(another.field,value2)') 142 | rs = rs.filter(R().field.eq('value'), R().another.field.eq('value2')) 143 | ``` 144 | 145 | All the arguments will be combined with logical `and`. 146 | 147 | Filters can be also specified as keyword argument using the `__` (double underscore) 148 | notation. 149 | 150 | ```py3 151 | rs = rs.filter( 152 | field=value, 153 | another__field=value, 154 | field2__in=('a', 'b'), 155 | field3__null=True, 156 | ) 157 | ``` 158 | 159 | Also keyword arguments will be combined with logical `and`. 160 | 161 | Returns: 162 | (ResourceSet): Returns a copy of the current ResourceSet with the filter applied. 163 | """ 164 | copy = self._copy() 165 | for arg in args: 166 | if isinstance(arg, str): 167 | copy._query &= R(_expr=arg) 168 | continue 169 | if isinstance(arg, R): 170 | copy._query &= arg 171 | continue 172 | raise TypeError(f'arguments must be string or R not {type(arg)}') 173 | 174 | if kwargs: 175 | copy._query &= R(**kwargs) 176 | 177 | return copy 178 | 179 | def all(self): 180 | """ 181 | Returns a copy of the current ResourceSet. 182 | 183 | Returns: 184 | (ResourceSet): Returns a copy of the current ResourceSet. 185 | """ 186 | return self._copy() 187 | 188 | def search(self, term: str): 189 | """ 190 | Create a copy of the current ResourceSet applying the `search` RQL 191 | operator equal to `term`. 192 | 193 | Args: 194 | term (str): The term to search for. 195 | 196 | Returns: 197 | (ResourceSet): Create a copy of the current ResourceSet applying the `search` RQL 198 | operator equal to `term`. 199 | """ 200 | copy = self._copy() 201 | copy._search = term 202 | return copy 203 | 204 | def values_list(self, *fields): 205 | """ 206 | Returns a flat dictionary containing only the fields passed as arguments 207 | for each resource that belongs to this ResourceSet. 208 | 209 | Nested field can be specified using dot notation. 210 | 211 | Usage: 212 | 213 | ```py3 214 | values = rs.values_list('field', 'nested.field') 215 | ``` 216 | """ 217 | if self._results: 218 | self._fields = fields 219 | return [self._get_values(item) for item in self._results] 220 | 221 | copy = self._copy() 222 | copy._fields = fields 223 | return copy 224 | 225 | def _get_values(self, item): 226 | return {field: resolve_attribute(field, item) for field in self._fields} 227 | 228 | def _build_qs(self): 229 | qs = '' 230 | if self._select: 231 | qs += f'&select({",".join(self._select)})' 232 | if self._query: 233 | qs += f'&{str(self._query)}' 234 | if self._ordering: 235 | qs += f'&ordering({",".join(self._ordering)})' 236 | return qs[1:] if qs else '' 237 | 238 | def _get_request_url(self): 239 | url = f'{self._path}' 240 | qs = self._build_qs() 241 | if qs: 242 | url = f'{url}?{qs}' 243 | 244 | return url 245 | 246 | def _get_request_kwargs(self): 247 | config = copy.deepcopy(self._config) 248 | config.setdefault('params', {}) 249 | 250 | config['params'].update( 251 | { 252 | 'limit': self._limit, 253 | 'offset': self._offset, 254 | }, 255 | ) 256 | 257 | if self._search: 258 | config['params']['search'] = self._search 259 | 260 | return config 261 | 262 | def _copy(self): 263 | rs = self.__class__(self._client, self._path, self._query) 264 | rs._limit = self._limit 265 | rs._offset = self._offset 266 | rs._slice = self._slice 267 | rs._fields = self._fields 268 | rs._search = self._search 269 | rs._select = copy.copy(self._select) 270 | rs._ordering = copy.copy(self._ordering) 271 | rs._config = copy.deepcopy(self._config) 272 | 273 | return rs 274 | 275 | def _validate_key(self, key): 276 | if not isinstance(key, (int, slice)): 277 | raise TypeError('ResourceSet indices must be integers or slices.') 278 | 279 | if isinstance(key, slice) and (key.start is None or key.stop is None): 280 | raise ValueError('Both start and stop indexes must be specified.') 281 | 282 | if (not isinstance(key, slice) and (key < 0)) or ( 283 | isinstance(key, slice) and (key.start < 0 or key.stop < 0) 284 | ): 285 | raise ValueError('Negative indexing is not supported.') 286 | 287 | if isinstance(key, slice) and not (key.step is None or key.step == 0): 288 | raise ValueError('Indexing with step is not supported.') 289 | 290 | def help(self): 291 | self._client.print_help(self) 292 | return self 293 | 294 | 295 | class ResourceSet(_ResourceSetBase): 296 | """ 297 | Represent a set of resources. 298 | 299 | Usage: 300 | 301 | ```py3 302 | for product in client.products.all().filter( 303 | R().status.eq('published') 304 | ).order_by('created'): 305 | ... 306 | ``` 307 | """ 308 | 309 | def __iter__(self): 310 | if self._results is None: 311 | return self._iterator() 312 | return iter(self._results) 313 | 314 | def __bool__(self): 315 | if self._results is not None: 316 | return bool(self._results) 317 | copy = self._copy() 318 | copy._fetch_all() 319 | return bool(copy._results) 320 | 321 | def __getitem__(self, key): # noqa: CCR001 322 | self._validate_key(key) 323 | 324 | if self._results is not None: 325 | return self._results[key] 326 | 327 | if isinstance(key, int): 328 | copy = self._copy() 329 | copy._limit = 1 330 | copy._offset = key 331 | copy._fetch_all() 332 | return copy._results[0] if copy._results else None 333 | 334 | copy = self._copy() 335 | copy._offset = key.start 336 | copy._slice = key 337 | if copy._slice.stop - copy._slice.start < copy._limit: 338 | copy._limit = copy._slice.stop - copy._slice.start 339 | 340 | return copy 341 | 342 | def count(self) -> int: 343 | """ 344 | Returns the total number of resources within this ResourceSet object. 345 | 346 | Usage: 347 | 348 | ```py3 349 | no_of_products = client.products.all().count() 350 | ``` 351 | 352 | Returns: 353 | (int): Returns the total number of resources within this ResourceSet object. 354 | """ 355 | if not self._content_range: 356 | copy = self._copy() 357 | url = copy._get_request_url() 358 | kwargs = copy._get_request_kwargs() 359 | kwargs['params']['limit'] = 0 360 | copy._execute_request(url, kwargs) 361 | return copy._content_range.count 362 | return self._content_range.count 363 | 364 | def first(self): 365 | """ 366 | Returns the first resource that belongs to this ResourceSet object 367 | or None if the ResourceSet doesn't contains resources. 368 | 369 | Usage: 370 | 371 | ```py3 372 | latest_news = client.news.all().order_by('-updated').first() 373 | ``` 374 | 375 | Returns: 376 | (Resource): Returns the first resource that belongs to this ResourceSet object 377 | or None. 378 | """ 379 | copy = self._copy() 380 | copy._limit = 1 381 | copy._offset = 0 382 | copy._fetch_all() 383 | return copy._results[0] if copy._results else None 384 | 385 | def _iterator(self): 386 | args = ( 387 | self, 388 | self._client, 389 | self._path, 390 | self._build_qs(), 391 | self._get_request_kwargs(), 392 | ) 393 | iterator = ( 394 | ValuesListIterator(*args, fields=self._fields) 395 | if self._fields 396 | else ResourceIterator(*args) 397 | ) 398 | return iterator 399 | 400 | def _execute_request(self, url, kwargs): 401 | results = self._client.get(url, **kwargs) 402 | self._content_range = parse_content_range( 403 | self._client.response.headers.get('Content-Range'), 404 | ) 405 | return results 406 | 407 | def _fetch_all(self): 408 | if self._results is None: 409 | self._results = self._execute_request( 410 | self._get_request_url(), 411 | self._get_request_kwargs(), 412 | ) 413 | 414 | 415 | class AsyncResourceSet(_ResourceSetBase): 416 | """ 417 | Represent a set of resources. 418 | 419 | Usage: 420 | 421 | ```py3 422 | async for product in ( 423 | client.products.all().filter( 424 | R().status.eq('published') 425 | ).order_by('created') 426 | ): 427 | ... 428 | ``` 429 | """ 430 | 431 | def __aiter__(self): 432 | if self._results is None: 433 | return self._iterator() 434 | return aiter(self._results) 435 | 436 | def __bool__(self): 437 | if self._results is None: 438 | raise NotYetEvaluatedError() 439 | return bool(self._results) 440 | 441 | def __getitem__(self, key): # noqa: CCR001 442 | self._validate_key(key) 443 | 444 | if self._results is not None: 445 | return self._results[key] 446 | 447 | if isinstance(key, int): 448 | raise NotYetEvaluatedError() 449 | 450 | copy = self._copy() 451 | copy._offset = key.start 452 | copy._slice = key 453 | if copy._slice.stop - copy._slice.start < copy._limit: 454 | copy._limit = copy._slice.stop - copy._slice.start 455 | 456 | return copy 457 | 458 | async def count(self) -> int: 459 | """ 460 | Returns the total number of resources within this ResourceSet object. 461 | 462 | Usage: 463 | 464 | ```py3 465 | no_of_products = await client.products.all().count() 466 | ``` 467 | 468 | Returns: 469 | (int): Returns the total number of resources within this ResourceSet object. 470 | """ 471 | if not self._content_range: 472 | url = self._get_request_url() 473 | kwargs = self._get_request_kwargs() 474 | kwargs['params']['limit'] = 0 475 | await self._execute_request(url, kwargs) 476 | return self._content_range.count 477 | 478 | async def first(self): 479 | """ 480 | Returns the first resource that belongs to this ResourceSet object 481 | or None if the ResourceSet doesn't contains resources. 482 | 483 | Usage: 484 | 485 | ```py3 486 | latest_news = await client.news.all().order_by('-updated').first() 487 | ``` 488 | 489 | Returns: 490 | (Resource): Returns the first resource that belongs to this ResourceSet object 491 | or None. 492 | """ 493 | copy = self._copy() 494 | copy._limit = 1 495 | copy._offset = 0 496 | await copy._fetch_all() 497 | return copy._results[0] if copy._results else None 498 | 499 | def _iterator(self): 500 | args = ( 501 | self, 502 | self._client, 503 | self._path, 504 | self._build_qs(), 505 | self._get_request_kwargs(), 506 | ) 507 | 508 | iterator = ( 509 | AsyncValuesListIterator(*args, fields=self._fields) 510 | if self._fields 511 | else AsyncResourceIterator(*args) 512 | ) 513 | return iterator 514 | 515 | async def _execute_request(self, url, kwargs): 516 | results = await self._client.get(url, **kwargs) 517 | self._content_range = parse_content_range( 518 | self._client.response.headers.get('Content-Range'), 519 | ) 520 | return results 521 | 522 | async def _fetch_all(self): 523 | if self._results is None: # pragma: no branch 524 | self._results = await self._execute_request( 525 | self._get_request_url(), 526 | self._get_request_kwargs(), 527 | ) 528 | -------------------------------------------------------------------------------- /connect/client/openapi.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from functools import partial 7 | from io import StringIO 8 | from typing import ( 9 | Any, 10 | List, 11 | MutableSet, 12 | Optional, 13 | Tuple, 14 | ) 15 | 16 | import requests 17 | import yaml 18 | 19 | 20 | class OpenAPISpecs: 21 | def __init__(self, location: str): 22 | self._location = location 23 | self._specs = self._load() 24 | 25 | @property 26 | def title(self) -> Optional[str]: 27 | return self._specs['info']['title'] if self._specs else None 28 | 29 | @property 30 | def description(self) -> Optional[str]: 31 | return self._specs['info']['description'] if self._specs else None 32 | 33 | @property 34 | def version(self) -> Optional[str]: 35 | return self._specs['info']['version'] if self._specs else None 36 | 37 | @property 38 | def tags(self): 39 | return self._specs.get('tags') if self._specs else None 40 | 41 | def exists(self, method: str, path: str) -> bool: 42 | p = self._get_path(path) 43 | if not p: 44 | return False 45 | info = self._specs['paths'][p] 46 | return method.lower() in info 47 | 48 | def get_namespaces(self) -> List: 49 | def _is_namespace(path): 50 | comp = path[1:].split('/', 1) 51 | return len(comp) > 1 and not comp[1].startswith('{') 52 | 53 | return sorted( 54 | list( 55 | { 56 | p[1:].split('/', 1)[0] 57 | for p in filter(_is_namespace, self._specs['paths'].keys()) 58 | }, 59 | ), 60 | ) 61 | 62 | def get_collections(self) -> MutableSet: 63 | namespaces = self.get_namespaces() 64 | cols = set() 65 | for p in self._specs['paths'].keys(): 66 | name = p[1:].split('/', 1)[0] 67 | if name not in namespaces: 68 | cols.add(name) 69 | 70 | return sorted(cols) 71 | 72 | def get_namespaced_collections(self, path: str) -> MutableSet: 73 | nested = filter(lambda x: x[1:].startswith(path), self._specs['paths'].keys()) 74 | collections = set() 75 | for p in nested: 76 | splitted = p[len(path) + 1 :].split('/', 2) 77 | if self._is_collection(p) and len(splitted) == 2: 78 | collections.add(splitted[1]) 79 | return list(sorted(collections)) 80 | 81 | def get_collection(self, path: str): 82 | return self._get_info(path) 83 | 84 | def get_resource(self, path: str): 85 | return self._get_info(path) 86 | 87 | def get_action(self, path: str): 88 | return self._get_info(path) 89 | 90 | def get_actions(self, path: str) -> List: 91 | p = self._get_path(path) 92 | nested = filter( 93 | lambda x: x.startswith(p) and x != p, 94 | self._specs['paths'].keys(), 95 | ) 96 | 97 | actions = set() 98 | descriptions = {} 99 | for np in nested: 100 | name = np[len(p) + 1 :] 101 | actions.add(name) 102 | info = self._specs['paths'][np] 103 | summary = info['summary'] if 'summary' in info else '' 104 | if summary: 105 | descriptions[name] = summary 106 | return [(name, descriptions.get(name)) for name in sorted(actions)] 107 | 108 | def get_nested_namespaces(self, path) -> List: 109 | def _is_nested_namespace(base_path, path): 110 | if path[1:].startswith(base_path): 111 | comp = path[1:].split('/') 112 | return len(comp) > 1 and not comp[-1].startswith('{') 113 | return False 114 | 115 | nested = filter( 116 | partial(_is_nested_namespace, path), 117 | self._specs['paths'].keys(), 118 | ) 119 | current_level = len(path[1:].split('/')) 120 | nested_namespaces = [] 121 | for ns in nested: 122 | name = ns[1:].split('/')[current_level] 123 | if not self._is_collection(f'/{path}/{name}'): 124 | nested_namespaces.append(name) 125 | return nested_namespaces 126 | 127 | def get_nested_collections(self, path: str) -> List[Tuple]: 128 | p = self._get_path(path) 129 | nested = filter( 130 | lambda x: x.startswith(p[0 : p.rindex('{')]) and x != p, 131 | self._specs['paths'].keys(), 132 | ) 133 | cut_pos = p.count('/') 134 | collections = set() 135 | descriptions = {} 136 | for np in nested: 137 | splitted = np[1:].split('/') 138 | name = splitted[cut_pos] 139 | info = self._specs['paths'][np] 140 | method_info = info['get'] if 'get' in info else info['post'] 141 | operation_id = method_info['operationId'] 142 | if self._is_action(operation_id): 143 | continue 144 | collections.add(name) 145 | summary = info['summary'] if 'summary' in info else '' 146 | if summary: 147 | descriptions[name] = summary 148 | return [(name, descriptions.get(name)) for name in sorted(collections)] 149 | 150 | def _load(self) -> Any: 151 | if self._location.startswith('http'): 152 | return self._load_from_url() 153 | return self._load_from_fs() 154 | 155 | def _load_from_url(self) -> Any: 156 | res = requests.get(self._location, stream=True) 157 | if res.status_code == 200: 158 | result = StringIO() 159 | for chunk in res.iter_content(chunk_size=8192): 160 | result.write(str(chunk, encoding='utf-8')) 161 | result.seek(0) 162 | return yaml.safe_load(result) 163 | res.raise_for_status() 164 | 165 | def _load_from_fs(self) -> Any: 166 | with open(self._location, 'r') as f: 167 | return yaml.safe_load(f) 168 | 169 | def _get_path(self, path): 170 | if '?' in path: 171 | path, _ = path.split('?', 1) 172 | components = path.split('/') 173 | for p in self._specs['paths'].keys(): 174 | cmps = p[1:].split('/') 175 | if len(cmps) != len(components): 176 | continue 177 | for idx, comp in enumerate(components): 178 | if cmps[idx].startswith('{'): 179 | continue 180 | if cmps[idx] != comp: 181 | break 182 | else: 183 | return p 184 | 185 | def _get_info(self, path): 186 | p = self._get_path(path) 187 | return self._specs['paths'][p] if p else None 188 | 189 | def _is_action(self, operation_id): 190 | op_id_cmps = operation_id.rsplit('_', 2) 191 | return op_id_cmps[-2] not in ('list', 'retrieve') 192 | 193 | def _is_collection(self, path): 194 | path_length = len(path[1:].split('/')) 195 | for p in self._specs['paths'].keys(): 196 | comp = p[1:].split('/') 197 | if not p.startswith(path): 198 | continue 199 | if p == path: 200 | return True 201 | if len(comp) > path_length and comp[path_length].startswith('{'): 202 | return True 203 | return False 204 | -------------------------------------------------------------------------------- /connect/client/rql/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.rql.base import R # noqa 7 | -------------------------------------------------------------------------------- /connect/client/rql/base.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from typing import List 7 | 8 | from connect.client.rql.utils import parse_kwargs, to_rql_value 9 | 10 | 11 | class RQLQuery: 12 | """ 13 | Helper class to construct complex RQL queries. 14 | 15 | Usage: 16 | 17 | ```py3 18 | rql = R(field='value', field2__in=('v1', 'v2'), field3__empty=True) 19 | ``` 20 | !!! note 21 | All the lookups expressed as keyword arguments are combined together with a logical `and`. 22 | 23 | 24 | Using the ``n`` method: 25 | 26 | ```py3 27 | rql = ( 28 | R().n('field').eq('value') 29 | & R().n('field2').anyof(('v1', 'v2')) 30 | & R().n('field3').empty(True) 31 | ) 32 | ``` 33 | 34 | The previous query can be expressed in a more concise form like: 35 | 36 | ```py3 37 | rql = R().field.eq('value') & R().field2.anyof(('v1', 'v2')) & r.field3.empty(True) 38 | ``` 39 | 40 | The R object support the bitwise operators `&`, `|` and `~`. 41 | 42 | Nested fields can be expressed using dot notation: 43 | 44 | ```py3 45 | rql = R().n('nested.field').eq('value') 46 | ``` 47 | 48 | or 49 | 50 | ```py3 51 | rql = R().nested.field.eq('value') 52 | ``` 53 | """ 54 | 55 | AND = 'and' 56 | OR = 'or' 57 | EXPR = 'expr' 58 | 59 | def __init__(self, *, _op=EXPR, _children=None, _negated=False, _expr=None, **kwargs): 60 | self.op = _op 61 | self.children = _children or [] 62 | self.negated = _negated 63 | self.expr = _expr 64 | self._path = [] 65 | self._field = None 66 | if len(kwargs) == 1: 67 | self.op = self.EXPR 68 | self.expr = parse_kwargs(kwargs)[0] 69 | if len(kwargs) > 1: 70 | self.op = self.AND 71 | for token in parse_kwargs(kwargs): 72 | self.children.append(RQLQuery(_expr=token)) 73 | 74 | def __len__(self): 75 | if self.op == self.EXPR: 76 | if self.expr: 77 | return 1 78 | return 0 79 | return len(self.children) 80 | 81 | def __bool__(self): 82 | return bool(self.children) or bool(self.expr) 83 | 84 | def __eq__(self, other): 85 | return ( 86 | self.op == other.op 87 | and self.children == other.children 88 | and self.negated == other.negated 89 | and self.expr == other.expr 90 | ) 91 | 92 | def __hash__(self): 93 | return hash( 94 | (self.op, self.expr, self.negated, *(hash(value) for value in self.children)), 95 | ) 96 | 97 | def __repr__(self): 98 | if self.op == self.EXPR: 99 | return f'' 100 | return f'' 101 | 102 | def __and__(self, other): 103 | return self._join(other, self.AND) 104 | 105 | def __or__(self, other): 106 | return self._join(other, self.OR) 107 | 108 | def __invert__(self): 109 | query = RQLQuery(_op=self.AND, _expr=self.expr, _negated=True) 110 | query._append(self) 111 | return query 112 | 113 | def __getattr__(self, name): 114 | return self.n(name) 115 | 116 | def __str__(self): 117 | return self._to_string(self) 118 | 119 | def n(self, name): 120 | """ 121 | Set the current field for this `R` object. 122 | 123 | Args: 124 | name (str): Name of the field. 125 | """ 126 | if self._field: 127 | raise AttributeError('Already evaluated') 128 | 129 | self._path.extend(name.split('.')) 130 | return self 131 | 132 | def ne(self, value): 133 | """ 134 | Apply the `ne` operator to the field this `R` object refers to. 135 | 136 | Args: 137 | value (str): The value to which compare the field. 138 | """ 139 | return self._bin('ne', value) 140 | 141 | def eq(self, value): 142 | """ 143 | Apply the `eq` operator to the field this `R` object refers to. 144 | 145 | Args: 146 | value (str): The value to which compare the field. 147 | """ 148 | return self._bin('eq', value) 149 | 150 | def lt(self, value): 151 | """ 152 | Apply the `lt` operator to the field this `R` object refers to. 153 | 154 | Args: 155 | value (str): The value to which compare the field. 156 | """ 157 | return self._bin('lt', value) 158 | 159 | def le(self, value): 160 | """ 161 | Apply the `le` operator to the field this `R` object refers to. 162 | 163 | Args: 164 | value (str): The value to which compare the field. 165 | """ 166 | return self._bin('le', value) 167 | 168 | def gt(self, value): 169 | """ 170 | Apply the `gt` operator to the field this `R` object refers to. 171 | 172 | Args: 173 | value (str): The value to which compare the field. 174 | """ 175 | return self._bin('gt', value) 176 | 177 | def ge(self, value): 178 | """ 179 | Apply the `ge` operator to the field this `R` object refers to. 180 | 181 | Args: 182 | value (str): The value to which compare the field. 183 | """ 184 | return self._bin('ge', value) 185 | 186 | def out(self, value: List[str]): 187 | """ 188 | Apply the `out` operator to the field this `R` object refers to. 189 | 190 | Args: 191 | value (list[str]): The list of values to which compare the field. 192 | """ 193 | return self._list('out', value) 194 | 195 | def in_(self, value): 196 | return self._list('in', value) 197 | 198 | def oneof(self, value: List[str]): 199 | """ 200 | Apply the `in` operator to the field this `R` object refers to. 201 | 202 | Args: 203 | value (list[str]): The list of values to which compare the field. 204 | """ 205 | return self._list('in', value) 206 | 207 | def null(self, value: List[str]): 208 | """ 209 | Apply the `null` operator to the field this `R` object refers to. 210 | 211 | Args: 212 | value (list[str]): The value to which compare the field. 213 | """ 214 | return self._bool('null', value) 215 | 216 | def empty(self, value: List[str]): 217 | """ 218 | Apply the `empty` operator to the field this `R` object refers to. 219 | 220 | Args: 221 | value (list[str]): The value to which compare the field. 222 | """ 223 | return self._bool('empty', value) 224 | 225 | def like(self, value: List[str]): 226 | """ 227 | Apply the `like` operator to the field this `R` object refers to. 228 | 229 | Args: 230 | value (list[str]): The value to which compare the field. 231 | """ 232 | return self._bin('like', value) 233 | 234 | def ilike(self, value: List[str]): 235 | """ 236 | Apply the `ilike` operator to the field this `R` object refers to. 237 | 238 | Args: 239 | value (list[str]): The value to which compare the field. 240 | """ 241 | return self._bin('ilike', value) 242 | 243 | def _bin(self, op, value): 244 | self._field = '.'.join(self._path) 245 | value = to_rql_value(op, value) 246 | self.expr = f'{op}({self._field},{value})' 247 | return self 248 | 249 | def _list(self, op, value): 250 | self._field = '.'.join(self._path) 251 | value = to_rql_value(op, value) 252 | self.expr = f'{op}({self._field},({value}))' 253 | return self 254 | 255 | def _bool(self, expr, value): 256 | self._field = '.'.join(self._path) 257 | if bool(value) is False: 258 | self.expr = f'ne({self._field},{expr}())' 259 | return self 260 | self.expr = f'eq({self._field},{expr}())' 261 | return self 262 | 263 | def _to_string(self, query): 264 | tokens = [] 265 | if query.expr: 266 | if query.negated: 267 | return f'not({query.expr})' 268 | return query.expr 269 | for c in query.children: 270 | if c.expr: 271 | if c.negated: 272 | tokens.append(f'not({c.expr})') 273 | else: 274 | tokens.append(c.expr) 275 | continue 276 | tokens.append(self._to_string(c)) 277 | 278 | if not tokens: 279 | return '' 280 | 281 | if query.negated: 282 | return f'not({query.op}({",".join(tokens)}))' 283 | return f'{query.op}({",".join(tokens)})' 284 | 285 | def _copy(self, other): 286 | return RQLQuery( 287 | _op=other.op, 288 | _children=other.children[:], 289 | _expr=other.expr, 290 | ) 291 | 292 | def _join(self, other, op): 293 | if self == other: 294 | return self._copy(self) 295 | if not other: 296 | return self._copy(self) 297 | if not self: 298 | return self._copy(other) 299 | 300 | query = RQLQuery(_op=op) 301 | query._append(self) 302 | query._append(other) 303 | return query 304 | 305 | def _append(self, other): 306 | if other in self.children: 307 | return other 308 | 309 | if ( 310 | other.op == self.op or (len(other) == 1 and other.op != self.EXPR) 311 | ) and not other.negated: 312 | self.children.extend(other.children) 313 | return self 314 | 315 | self.children.append(other) 316 | return self 317 | 318 | 319 | R = RQLQuery 320 | -------------------------------------------------------------------------------- /connect/client/rql/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from datetime import date, datetime 7 | from decimal import Decimal 8 | 9 | 10 | COMP = ('eq', 'ne', 'lt', 'le', 'gt', 'ge') 11 | SEARCH = ('like', 'ilike') 12 | LIST = ('in', 'out') 13 | NULL = 'null' 14 | EMPTY = 'empty' 15 | 16 | KEYWORDS = (*COMP, *SEARCH, *LIST, NULL, EMPTY) 17 | 18 | 19 | def parse_kwargs(query_dict): 20 | query = [] 21 | for lookup, value in query_dict.items(): 22 | tokens = lookup.split('__') 23 | if len(tokens) == 1: 24 | # field=value 25 | field = tokens[0] 26 | value = to_rql_value('eq', value) 27 | query.append(f'eq({field},{value})') 28 | continue 29 | op = tokens[-1] 30 | if op not in KEYWORDS: 31 | # field__nested=value 32 | field = '.'.join(tokens) 33 | value = to_rql_value('eq', value) 34 | query.append(f'eq({field},{value})') 35 | continue 36 | field = '.'.join(tokens[:-1]) 37 | if op in COMP or op in SEARCH: 38 | value = to_rql_value(op, value) 39 | query.append(f'{op}({field},{value})') 40 | continue 41 | if op in LIST: 42 | value = to_rql_value(op, value) 43 | query.append(f'{op}({field},({value}))') 44 | continue 45 | 46 | cmpop = 'eq' if value is True else 'ne' 47 | expr = 'null()' if op == NULL else 'empty()' 48 | query.append(f'{cmpop}({field},{expr})') 49 | 50 | return query 51 | 52 | 53 | def to_rql_value(op, value): 54 | if op not in LIST: 55 | if isinstance(value, str): 56 | return value 57 | if isinstance(value, bool): 58 | return 'true' if value else 'false' 59 | if isinstance(value, (int, float, Decimal)): 60 | return str(value) 61 | if isinstance(value, (date, datetime)): 62 | return value.isoformat() 63 | if op in LIST and isinstance(value, (list, tuple)): 64 | return ','.join(value) 65 | raise TypeError(f"the `{op}` operator doesn't support the {type(value)} type.") 66 | -------------------------------------------------------------------------------- /connect/client/testing/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.testing.fluent import ( # noqa 7 | AsyncConnectClientMocker, 8 | ConnectClientMocker, 9 | get_httpx_mocker, 10 | get_requests_mocker, 11 | ) 12 | -------------------------------------------------------------------------------- /connect/client/testing/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from connect.client.testing import ( 4 | AsyncConnectClientMocker, 5 | ConnectClientMocker, 6 | get_httpx_mocker, 7 | get_requests_mocker, 8 | ) 9 | 10 | 11 | @pytest.fixture 12 | def client_mocker_factory(request): 13 | """ 14 | This fixture allows to instantiate a ConnectClient mocker 15 | to mock http calls made from the ConnectClient in an easy way. 16 | """ 17 | mocker = None 18 | 19 | def _wrapper(base_url='https://example.org/public/v1', exclude=None): 20 | nonlocal mocker 21 | mocker = ConnectClientMocker(base_url, exclude=exclude) 22 | mocker.start() 23 | return mocker 24 | 25 | def _finalizer(): 26 | if mocker: # pragma: no cover 27 | mocker.reset() 28 | 29 | request.addfinalizer(_finalizer) 30 | return _wrapper 31 | 32 | 33 | @pytest.fixture 34 | def async_client_mocker_factory(request): 35 | """ 36 | This fixture allows to instantiate a AsyncConnectClient mocker 37 | to mock http calls made from the AsyncConnectClient in an easy way. 38 | """ 39 | mocker = None 40 | 41 | def _wrapper(base_url='https://example.org/public/v1', exclude=None): 42 | mocker = AsyncConnectClientMocker(base_url, exclude=exclude) 43 | mocker.start() 44 | return mocker 45 | 46 | def _finalizer(): 47 | if mocker: # pragma: no cover 48 | mocker.reset() 49 | 50 | request.addfinalizer(_finalizer) 51 | return _wrapper 52 | 53 | 54 | @pytest.fixture 55 | def requests_mocker(): 56 | """ 57 | This fixture allows you to mock http calls made using the `requests` library 58 | when they are made in conjunction with calls made with the `ConnectClient`. 59 | The returned mocker is the one provided by the 60 | [responses](https://github.com/getsentry/responses) library. 61 | """ 62 | return get_requests_mocker() 63 | 64 | 65 | @pytest.fixture 66 | def httpx_mocker(): 67 | """ 68 | This fixture allows you to mock http calls made using the `httpx` library 69 | when they are made in conjunction with calls made with the `AsyncConnectClient`. 70 | The returned mocker is the one provided by the 71 | [pytest-httpx](https://colin-b.github.io/pytest_httpx/) library. 72 | """ 73 | return get_httpx_mocker() 74 | -------------------------------------------------------------------------------- /connect/client/testing/fluent.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | import json 7 | import re 8 | 9 | import httpx 10 | import responses 11 | from pytest import MonkeyPatch 12 | from pytest_httpx import HTTPXMock 13 | from responses import matchers 14 | 15 | from connect.client.fluent import _ConnectClientBase 16 | from connect.client.testing.models import CollectionMock, NSMock 17 | 18 | 19 | def body_matcher(body): 20 | def match(request): 21 | request_body = request.body 22 | valid = body is None if request_body is None else body == request_body 23 | if not valid: 24 | return False, "%s doesn't match %s" % (request_body, body) 25 | 26 | return valid, '' 27 | 28 | return match 29 | 30 | 31 | _mocker = responses.RequestsMock() 32 | 33 | 34 | class ConnectClientMocker(_ConnectClientBase): 35 | def __init__(self, base_url, exclude=None): 36 | super().__init__('api_key', endpoint=base_url) 37 | if exclude: 38 | if not isinstance(exclude, (list, tuple, set)): 39 | exclude = [exclude] 40 | for item in exclude: 41 | _mocker.add_passthru(item) 42 | 43 | def get( 44 | self, 45 | url, 46 | status_code=200, 47 | return_value=None, 48 | headers=None, 49 | ): 50 | return self.mock( 51 | 'get', 52 | url, 53 | status_code=status_code, 54 | return_value=return_value, 55 | headers=headers, 56 | ) 57 | 58 | def create( 59 | self, 60 | url, 61 | status_code=201, 62 | return_value=None, 63 | headers=None, 64 | match_body=None, 65 | ): 66 | return self.mock( 67 | 'post', 68 | url, 69 | status_code=status_code, 70 | return_value=return_value, 71 | headers=headers, 72 | match_body=match_body, 73 | ) 74 | 75 | def update( 76 | self, 77 | url, 78 | status_code=201, 79 | return_value=None, 80 | headers=None, 81 | match_body=None, 82 | ): 83 | return self.mock( 84 | 'put', 85 | url, 86 | status_code=status_code, 87 | return_value=return_value, 88 | headers=headers, 89 | match_body=match_body, 90 | ) 91 | 92 | def delete( 93 | self, 94 | url, 95 | status_code=204, 96 | return_value=None, 97 | headers=None, 98 | match_body=None, 99 | ): 100 | return self.mock( 101 | 'delete', 102 | url, 103 | status_code=status_code, 104 | return_value=return_value, 105 | headers=headers, 106 | match_body=match_body, 107 | ) 108 | 109 | def mock( 110 | self, 111 | method, 112 | path, 113 | status_code=200, 114 | return_value=None, 115 | headers=None, 116 | match_body=None, 117 | ): 118 | url = f'{self.endpoint}/{path}' 119 | 120 | kwargs = { 121 | 'method': method.upper(), 122 | 'url': url, 123 | 'status': status_code, 124 | 'headers': headers, 125 | } 126 | 127 | if isinstance(return_value, (dict, list, tuple)): 128 | kwargs['json'] = return_value 129 | else: 130 | kwargs['body'] = return_value 131 | 132 | if match_body: 133 | if isinstance(match_body, (dict, list, tuple)): 134 | kwargs['match'] = [ 135 | matchers.json_params_matcher(match_body), 136 | ] 137 | else: 138 | kwargs['match'] = [ 139 | body_matcher(match_body), 140 | ] 141 | 142 | _mocker.add(**kwargs) 143 | 144 | def start(self): 145 | _mocker.start() 146 | 147 | def reset(self, success=True): 148 | try: 149 | _mocker.stop(allow_assert=success) 150 | finally: 151 | _mocker.reset() 152 | 153 | def __enter__(self): 154 | self.start() 155 | return self 156 | 157 | def __exit__(self, exc_type, value, traceback): 158 | self.reset(success=exc_type is None) 159 | 160 | def _get_collection_class(self): 161 | return CollectionMock 162 | 163 | def _get_namespace_class(self): 164 | return NSMock 165 | 166 | 167 | _monkeypatch = MonkeyPatch() 168 | _async_mocker = HTTPXMock() 169 | 170 | 171 | class AsyncConnectClientMocker(ConnectClientMocker): 172 | def __init__(self, base_url, exclude=None): 173 | super().__init__(base_url) 174 | self.exclude = exclude or [] 175 | 176 | def start(self): 177 | patterns = self.exclude if isinstance(self.exclude, (list, tuple, set)) else [self.exclude] 178 | real_handle_async_request = httpx.AsyncHTTPTransport.handle_async_request 179 | 180 | async def mocked_handle_async_request( 181 | transport: httpx.AsyncHTTPTransport, request: httpx.Request 182 | ) -> httpx.Response: 183 | for pattern in patterns: 184 | if (isinstance(pattern, re.Pattern) and pattern.match(str(request.url))) or ( 185 | isinstance(pattern, str) and str(request.url).startswith(pattern) 186 | ): 187 | return await real_handle_async_request(transport, request) 188 | return await _async_mocker._handle_async_request(transport, request) 189 | 190 | _monkeypatch.setattr( 191 | httpx.AsyncHTTPTransport, 192 | "handle_async_request", 193 | mocked_handle_async_request, 194 | ) 195 | 196 | def reset(self, success=True): 197 | _async_mocker.reset(success) 198 | _monkeypatch.undo() 199 | 200 | def mock( 201 | self, 202 | method, 203 | path, 204 | status_code=200, 205 | return_value=None, 206 | headers=None, 207 | match_body=None, 208 | ): 209 | url = f'{self.endpoint}/{path}' 210 | 211 | kwargs = { 212 | 'method': method.upper(), 213 | 'url': url, 214 | 'status_code': status_code, 215 | 'headers': headers, 216 | } 217 | 218 | if isinstance(return_value, (dict, list, tuple)): 219 | kwargs['json'] = return_value 220 | else: 221 | kwargs['content'] = return_value.encode() if return_value else None 222 | 223 | if match_body: 224 | if isinstance(match_body, (dict, list, tuple)): 225 | kwargs['match_content'] = json.dumps(match_body).encode('utf-8') 226 | else: 227 | kwargs['match_content'] = match_body 228 | 229 | _async_mocker.add_response(**kwargs) 230 | 231 | 232 | def get_requests_mocker(): 233 | """ 234 | Returns a mocker object to mock http calls made using the `requests` library 235 | when they are made in conjunction with calls made with the `ConnectClient`. 236 | The returned mocker is the one provided by the 237 | [responses](https://github.com/getsentry/responses) library. 238 | """ 239 | return _mocker 240 | 241 | 242 | def get_httpx_mocker(): 243 | """ 244 | Returns a mocker object to mock http calls made using the `httpx` library 245 | when they are made in conjunction with calls made with the `AsyncConnectClient`. 246 | The returned mocker is the one provided by the 247 | [pytest-httpx](https://colin-b.github.io/pytest_httpx/) library. 248 | """ 249 | return _async_mocker 250 | -------------------------------------------------------------------------------- /connect/client/testing/models/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.testing.models.base import ( # noqa 7 | ActionMock, 8 | CollectionMock, 9 | NSMock, 10 | ResourceMock, 11 | ) 12 | -------------------------------------------------------------------------------- /connect/client/testing/models/base.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | from connect.client.models.base import ( 7 | _ActionBase, 8 | _CollectionBase, 9 | _NSBase, 10 | _ResourceBase, 11 | ) 12 | from connect.client.testing.models.mixins import ActionMixin, CollectionMixin, ResourceMixin 13 | from connect.client.testing.models.resourceset import ResourceSetMock 14 | 15 | 16 | class NSMock(_NSBase): 17 | def _get_collection_class(self): 18 | return CollectionMock 19 | 20 | def _get_namespace_class(self): 21 | return NSMock 22 | 23 | 24 | class CollectionMock(_CollectionBase, CollectionMixin): 25 | def _get_resource_class(self): 26 | return ResourceMock 27 | 28 | def _get_resourceset_class(self): 29 | return ResourceSetMock 30 | 31 | def _get_action_class(self): 32 | return ActionMock 33 | 34 | 35 | class ResourceMock(_ResourceBase, ResourceMixin): 36 | def _get_collection_class(self): 37 | return CollectionMock 38 | 39 | def _get_action_class(self): 40 | return ActionMock 41 | 42 | 43 | class ActionMock(_ActionBase, ActionMixin): 44 | pass 45 | -------------------------------------------------------------------------------- /connect/client/testing/models/mixins.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | 7 | 8 | class CollectionMixin: 9 | def create( 10 | self, 11 | status_code=201, 12 | return_value=None, 13 | headers=None, 14 | match_body=None, 15 | ): 16 | return self._client.create( 17 | self._path, 18 | status_code=status_code, 19 | return_value=return_value, 20 | headers=headers, 21 | match_body=match_body, 22 | ) 23 | 24 | def bulk_create( 25 | self, 26 | status_code=201, 27 | return_value=None, 28 | headers=None, 29 | match_body=None, 30 | ): 31 | return self._client.create( 32 | self._path, 33 | status_code=status_code, 34 | return_value=return_value, 35 | headers=headers, 36 | match_body=match_body, 37 | ) 38 | 39 | def bulk_update( 40 | self, 41 | status_code=200, 42 | return_value=None, 43 | headers=None, 44 | match_body=None, 45 | ): 46 | return self._client.update( 47 | self._path, 48 | status_code=status_code, 49 | return_value=return_value, 50 | headers=headers, 51 | match_body=match_body, 52 | ) 53 | 54 | def bulk_delete( 55 | self, 56 | status_code=204, 57 | return_value=None, 58 | headers=None, 59 | match_body=None, 60 | ): 61 | return self._client.delete( 62 | self._path, 63 | status_code=status_code, 64 | return_value=return_value, 65 | headers=headers, 66 | match_body=match_body, 67 | ) 68 | 69 | 70 | class ResourceMixin: 71 | def exists(self, return_value): 72 | self.get(status_code=200 if return_value else 404) 73 | 74 | def get( 75 | self, 76 | status_code=200, 77 | return_value=None, 78 | headers=None, 79 | ): 80 | return self._client.get( 81 | self._path, 82 | status_code=status_code, 83 | return_value=return_value, 84 | headers=headers, 85 | ) 86 | 87 | def update( 88 | self, 89 | status_code=200, 90 | return_value=None, 91 | headers=None, 92 | match_body=None, 93 | ): 94 | return self._client.update( 95 | self._path, 96 | status_code=status_code, 97 | return_value=return_value, 98 | headers=headers, 99 | match_body=match_body, 100 | ) 101 | 102 | def delete( 103 | self, 104 | status_code=204, 105 | return_value=None, 106 | headers=None, 107 | ): 108 | return self._client.delete( 109 | self._path, 110 | status_code=status_code, 111 | return_value=return_value, 112 | headers=headers, 113 | ) 114 | 115 | def values( 116 | self, 117 | status_code=200, 118 | return_value=None, 119 | headers=None, 120 | ): 121 | return self._client.get( 122 | self._path, 123 | status_code=status_code, 124 | return_value=return_value, 125 | headers=headers, 126 | ) 127 | 128 | 129 | class ActionMixin: 130 | def get( 131 | self, 132 | status_code=200, 133 | return_value=None, 134 | headers=None, 135 | ): 136 | return self._client.get( 137 | self._path, 138 | status_code=status_code, 139 | return_value=return_value, 140 | headers=headers, 141 | ) 142 | 143 | def post( 144 | self, 145 | status_code=200, 146 | return_value=None, 147 | headers=None, 148 | match_body=None, 149 | ): 150 | return self._client.create( 151 | self._path, 152 | status_code=status_code, 153 | return_value=return_value, 154 | headers=headers, 155 | match_body=match_body, 156 | ) 157 | 158 | def put( 159 | self, 160 | status_code=200, 161 | return_value=None, 162 | headers=None, 163 | match_body=None, 164 | ): 165 | return self._client.update( 166 | self._path, 167 | status_code=status_code, 168 | return_value=return_value, 169 | headers=headers, 170 | match_body=match_body, 171 | ) 172 | 173 | def delete( 174 | self, 175 | status_code=204, 176 | return_value=None, 177 | headers=None, 178 | ): 179 | return self._client.delete( 180 | self._path, 181 | status_code=status_code, 182 | return_value=return_value, 183 | headers=headers, 184 | ) 185 | -------------------------------------------------------------------------------- /connect/client/testing/models/resourceset.py: -------------------------------------------------------------------------------- 1 | from connect.client.models.resourceset import _ResourceSetBase 2 | 3 | 4 | class ResourceSetMock(_ResourceSetBase): 5 | def __init__(self, *args, **kwargs): 6 | super().__init__(*args, **kwargs) 7 | self._count = None 8 | 9 | def __getitem__(self, key): # noqa: CCR001 10 | self._validate_key(key) 11 | 12 | if isinstance(key, int): 13 | copy = self._copy() 14 | copy._limit = 1 15 | copy._offset = key 16 | return copy 17 | 18 | copy = self._copy() 19 | copy._offset = key.start 20 | copy._slice = key 21 | if copy._slice.stop - copy._slice.start < copy._limit: 22 | copy._limit = copy._slice.stop - copy._slice.start 23 | 24 | return copy 25 | 26 | def count(self, return_value=0, status_code=200, headers=None): 27 | headers = headers or {} 28 | if self._count is None: 29 | request_kwargs = self._get_request_kwargs() 30 | url = self._build_full_url( 31 | 0, 32 | 0, 33 | request_kwargs['params'].get('search'), 34 | ) 35 | if status_code == 200: 36 | if not isinstance(return_value, int): 37 | raise TypeError('return_value must be an integer') 38 | headers['Content-Range'] = f'items 0-0/{return_value}' 39 | self._client.get( 40 | url, 41 | return_value=[], 42 | headers=headers, 43 | ) 44 | self._count = return_value 45 | else: 46 | headers['Content-Range'] = f'items 0-0/{return_value}' 47 | self._client.get( 48 | url, 49 | status_code=status_code, 50 | headers=headers, 51 | ) 52 | 53 | def first(self): 54 | copy = self._copy() 55 | copy._limit = 1 56 | copy._offset = 0 57 | return copy 58 | 59 | def mock( 60 | self, 61 | status_code=200, 62 | return_value=None, 63 | headers=None, 64 | ): 65 | if status_code != 200: 66 | request_kwargs = self._get_request_kwargs() 67 | url = self._build_full_url( 68 | request_kwargs['params']['limit'], 69 | request_kwargs['params']['offset'], 70 | request_kwargs['params'].get('search'), 71 | ) 72 | self._client.get( 73 | url, 74 | status_code=status_code, 75 | return_value=return_value, 76 | headers=headers, 77 | ) 78 | return 79 | 80 | if not isinstance(return_value, list): 81 | raise TypeError('return_value must be a list of objects') 82 | 83 | if not self._slice: 84 | self._mock_iteration(return_value, headers) 85 | else: 86 | self._mock_slicing(return_value, headers) 87 | 88 | def _mock_iteration(self, return_value, extra_headers): 89 | request_kwargs = self._get_request_kwargs() 90 | total = len(return_value) 91 | self._count = 0 92 | 93 | if total == 0: 94 | url = self._build_full_url( 95 | request_kwargs['params']['limit'], 96 | 0, 97 | request_kwargs['params'].get('search'), 98 | ) 99 | headers = {'Content-Range': 'items 0-0/0'} 100 | 101 | if extra_headers: 102 | headers.update(extra_headers) 103 | self._client.get( 104 | url, 105 | return_value=[], 106 | headers=headers, 107 | ) 108 | return 109 | 110 | def pages_iterator(): 111 | for i in range(0, total, self._limit): 112 | yield return_value[i : i + self._limit], i 113 | 114 | for page, offset in pages_iterator(): 115 | url = self._build_full_url( 116 | request_kwargs['params']['limit'], 117 | offset, 118 | request_kwargs['params'].get('search'), 119 | ) 120 | headers = {'Content-Range': f'items {offset}-{offset + len(page) - 1}/{total}'} 121 | if extra_headers: 122 | headers.update(extra_headers) 123 | self._client.get( 124 | url, 125 | return_value=page, 126 | headers=headers, 127 | ) 128 | self._count += len(page) 129 | 130 | def _mock_slicing(self, return_value, extra_headers): 131 | request_kwargs = self._get_request_kwargs() 132 | total = len(return_value) 133 | self._count = 0 134 | 135 | if total == 0: 136 | url = self._build_full_url( 137 | request_kwargs['params']['limit'], 138 | 0, 139 | request_kwargs['params'].get('search'), 140 | ) 141 | headers = {'Content-Range': 'items 0-0/0'} 142 | 143 | if extra_headers: 144 | headers.update(extra_headers) 145 | self._client.get( 146 | url, 147 | return_value=[], 148 | headers=headers, 149 | ) 150 | return 151 | 152 | def pages_iterator(): 153 | limit = request_kwargs['params']['limit'] 154 | offset = request_kwargs['params']['offset'] 155 | last = offset - 1 156 | remaining = 0 157 | while True: 158 | page = return_value[offset : offset + limit] 159 | if not page or limit == 0: 160 | return 161 | yield page, limit, offset 162 | last += len(page) 163 | remaining = self._slice.stop - last - 1 164 | offset += limit 165 | if remaining < limit: 166 | limit = remaining 167 | 168 | for page, limit, offset in pages_iterator(): 169 | url = self._build_full_url( 170 | limit, 171 | offset, 172 | request_kwargs['params'].get('search'), 173 | ) 174 | headers = {'Content-Range': f'items {offset}-{offset + len(page) - 1}/{total}'} 175 | if extra_headers: 176 | headers.update(extra_headers) 177 | self._client.get( 178 | url, 179 | return_value=page, 180 | headers=headers, 181 | ) 182 | self._count += len(page) 183 | 184 | def _build_full_url(self, limit, offset, search=None): 185 | url = self._get_request_url() 186 | start = '&' if '?' in url else '?' 187 | params = f'{start}limit={limit}&offset={offset}' 188 | if search: 189 | params = f'{params}&search={search}' 190 | 191 | return f'{url}{params}' 192 | 193 | def _copy(self): 194 | rs = super()._copy() 195 | rs._count = self._count 196 | return rs 197 | -------------------------------------------------------------------------------- /connect/client/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 3 | # 4 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 5 | # 6 | import platform 7 | from collections import namedtuple 8 | 9 | from connect.client.version import get_version 10 | 11 | 12 | ContentRange = namedtuple('ContentRange', ('first', 'last', 'count')) 13 | 14 | 15 | def _get_user_agent(): 16 | version = get_version() 17 | pimpl = platform.python_implementation() 18 | pver = platform.python_version() 19 | sysname = platform.system() 20 | sysver = platform.release() 21 | ua = f'connect-fluent/{version} {pimpl}/{pver} {sysname}/{sysver}' 22 | return {'User-Agent': ua} 23 | 24 | 25 | def get_headers(api_key): 26 | headers = {'Authorization': api_key} 27 | headers.update(_get_user_agent()) 28 | return headers 29 | 30 | 31 | def parse_content_range(value): 32 | if not value: 33 | return 34 | _, info = value.split() 35 | first_last, count = info.split('/') 36 | first, last = first_last.split('-') 37 | return ContentRange(int(first), int(last), int(count)) 38 | 39 | 40 | def resolve_attribute(attr, data): 41 | try: 42 | for comp in attr.split('.'): 43 | data = data.get(comp) 44 | return data 45 | except: # noqa 46 | pass 47 | 48 | 49 | def get_values(item, fields): 50 | return {field: resolve_attribute(field, item) for field in fields} 51 | -------------------------------------------------------------------------------- /connect/client/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # 4 | # This file is part of the Ingram Micro CloudBlue Connect Python OpenAPI Client. 5 | # 6 | # Copyright (c) 2023 Ingram Micro. All Rights Reserved. 7 | # 8 | try: 9 | from importlib.metadata import version 10 | except Exception: 11 | from importlib_metadata import version 12 | 13 | 14 | def get_version(): 15 | try: 16 | return version('connect-openapi-client') 17 | except Exception: 18 | return '0.0.0' 19 | -------------------------------------------------------------------------------- /docs/async_client.md: -------------------------------------------------------------------------------- 1 | ## AsyncConnectClient 2 | 3 | ::: connect.client.AsyncConnectClient 4 | options: 5 | heading_level: 3 6 | 7 | ::: connect.client.fluent._ConnectClientBase 8 | options: 9 | heading_level: 3 10 | 11 | ::: connect.client.mixins.AsyncClientMixin 12 | options: 13 | heading_level: 3 14 | 15 | 16 | ## AsyncNS 17 | 18 | A **namespace** groups together a set of [**collections**](#asynccollection) of [**resources**](#asyncresource). 19 | 20 | ::: connect.client.models.AsyncNS 21 | options: 22 | heading_level: 3 23 | 24 | ::: connect.client.models.base._NSBase 25 | options: 26 | heading_level: 3 27 | 28 | 29 | ## AsyncCollection 30 | 31 | A **collection** is a set of [**resources**](#asyncresource) of the same type. 32 | 33 | ::: connect.client.models.AsyncCollection 34 | options: 35 | heading_level: 3 36 | 37 | ::: connect.client.models.base._CollectionBase 38 | options: 39 | heading_level: 3 40 | 41 | ::: connect.client.models.mixins.AsyncCollectionMixin 42 | options: 43 | heading_level: 3 44 | 45 | 46 | ## AsyncResource 47 | 48 | A **resource** is an object with a type, associated data, relationships to other resources 49 | and [**actions**](#aasyncction) that can be performed on such resource. 50 | 51 | ::: connect.client.models.AsyncResource 52 | options: 53 | heading_level: 3 54 | 55 | ::: connect.client.models.base._ResourceBase 56 | options: 57 | heading_level: 3 58 | 59 | ::: connect.client.models.mixins.AsyncResourceMixin 60 | options: 61 | heading_level: 3 62 | 63 | 64 | ## AsyncAction 65 | 66 | An **action** is an operation that can be performed on [**resources**](#asyncresource) 67 | or [**collections**](#asynccollection). 68 | 69 | ::: connect.client.models.AsyncAction 70 | options: 71 | heading_level: 3 72 | 73 | ::: connect.client.models.mixins.AsyncActionMixin 74 | options: 75 | heading_level: 3 76 | 77 | 78 | ## AsyncResourceSet 79 | 80 | A **ResourceSet** is a set of resources from one collection eventually filtered and ordered. 81 | 82 | ::: connect.client.models.AsyncResourceSet 83 | options: 84 | heading_level: 3 85 | 86 | ::: connect.client.models.resourceset._ResourceSetBase 87 | options: 88 | heading_level: 3 89 | -------------------------------------------------------------------------------- /docs/css/custom.css: -------------------------------------------------------------------------------- 1 | :root>* { 2 | --md-primary-fg-color: #1565c0; 3 | --md-primary-fg-color--light: #1565c0; 4 | --md-primary-fg-color--dark: #1565c0; 5 | } 6 | 7 | div.autodoc-docstring { 8 | padding-left: 20px; 9 | margin-bottom: 30px; 10 | border-left: 5px solid rgba(230, 230, 230); 11 | } 12 | 13 | div.autodoc-members { 14 | padding-left: 20px; 15 | margin-bottom: 15px; 16 | } 17 | 18 | :not([data-md-state="blur"]) + nav { 19 | display: none; 20 | } 21 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- 1 |

Unsupported browser

This portal is not supported by the web browser you're currently using

Please use one of the following browsers:
Chrome 58+, Firefox 55+, Safari 10+,
Opera 47+ or Microsoft Edge 16+

-------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | ## Terminology 2 | 3 | ### Resource 4 | 5 | A **resource** is an object with a type, associated data and relationships to other resources (example: `product`). 6 | 7 | ### Collection 8 | 9 | A **collection** is a set of **resources** of the same type (example: `products`). 10 | 11 | ### Namespace 12 | 13 | A **namespace** is a group of related **collections** (example: `localization`). 14 | 15 | ### Sub-namespace 16 | 17 | A **sub-namespace** is a **namespace** nested under a parent **namespace**. 18 | 19 | ### Sub-collection 20 | 21 | A **sub-collection** is a collection nested under a **resource** (example: `product items`). 22 | 23 | ### Action 24 | 25 | An **action** is an operation that can be performed on a **collection** or a **resource** (example: `approve request`). 26 | 27 | 28 | ## Requirements 29 | 30 | *connect-openapi-client* runs on python 3.8 or later. 31 | 32 | ## Install 33 | 34 | *connect-openapi-client* is a small python package that can be installed 35 | from the [pypi.org](https://pypi.org/project/connect-openapi-client/) repository. 36 | 37 | ``` 38 | $ pip install connect-openapi-client 39 | ``` 40 | 41 | ## Create a client instance 42 | 43 | To use *connect-openapi-client* first of all you have to create an instance of the `ConnectClient` object: 44 | 45 | ```python 46 | from connect.client import ConnectClient 47 | 48 | client = ConnectClient('ApiKey SU-000-000-000:xxxxxxxxxxxxxxxx') 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | Welcome to Connect Python OpenAPI Client documentation 2 | ====================================================== 3 | 4 | Welcome to `Connect Python OpenAPI Client` a simple, 5 | concise, powerful and REPL-friendly ReST API client. 6 | 7 | It has been designed following the [fluent interface design 8 | pattern](https://en.wikipedia.org/wiki/Fluent_interface). 9 | 10 | Due to its REPL-friendly nature, using the CloudBlue Connect OpenAPI 11 | specifications it allows developers to learn and play with the CloudBlue 12 | Connect API using a python REPL like [jupyter](https://jupyter.org/) or 13 | [ipython](https://ipython.org/). 14 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudblue/connect-python-openapi-client/dbbb0385baadb52e85d8d504b2c29d934ad577eb/docs/logo.png -------------------------------------------------------------------------------- /docs/logo_full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudblue/connect-python-openapi-client/dbbb0385baadb52e85d8d504b2c29d934ad577eb/docs/logo_full.png -------------------------------------------------------------------------------- /docs/namespaces_and_collections.md: -------------------------------------------------------------------------------- 1 | # Namespaces and collections 2 | 3 | The `ConnectClient` instance allows access to collections of resources using the 4 | `collection()` method of the client: 5 | 6 | ```python 7 | products = client.collection('products') 8 | ``` 9 | 10 | The previous call to the `collection()` method returns a 11 | `Collection` object that allows working with the resources that contain. 12 | 13 | Some collections of the CloudBlue Connect ReST API are grouped within a namespace. 14 | 15 | To access a namespace the client exposes the `ns()` method: 16 | 17 | ```python 18 | subscriptions = client.ns('subscriptions') 19 | ``` 20 | 21 | Since *Connect Python OpenAPI Client* has been designed following the fluent interface design pattern, 22 | you can chain methods: 23 | 24 | ```python 25 | assets = client.ns('subscriptions').collection('assets') 26 | ``` 27 | 28 | This previous previous expression can be written in a more concise way: 29 | 30 | ```python 31 | assets = client('subscriptions').assets 32 | ``` 33 | 34 | !!! note 35 | For collections that use a dash in their names, it is yet 36 | possible to use the concise form by replacing the dash character with an underscore. 37 | 38 | -------------------------------------------------------------------------------- /docs/querying_collections.md: -------------------------------------------------------------------------------- 1 | You can perform queries on a collection to retrieve a set of resources that 2 | match the filters specified in the query. 3 | 4 | The Connect ReST API use the [Resource Query Language](https://connect.cloudblue.com/community/api/rql/) 5 | or RQL, to perform queries on a collection. 6 | 7 | !!! note 8 | This guide assumes you are somewhat familiar with RQL. 9 | If not, take a look at the [RQL video tutorial here](https://connect.cloudblue.com/community/api/rql/#Video_Tutorial). 10 | 11 | 12 | The `ResourceSet` object helps both to express RQL queries and to manipulate the resulting set of resources. 13 | 14 | ## Create a `ResourceSet` object 15 | 16 | A `ResourceSet` object can be created through 17 | the corresponding `Collection` object 18 | using the `Collection.all()` method to access 19 | all the resources of the collection: 20 | 21 | ```python 22 | products = client.products.all() 23 | ``` 24 | 25 | Or applying filter using the `Collection.filter()` method: 26 | 27 | ```python 28 | products = client.products.filter(status='published') 29 | ``` 30 | 31 | The `ResourceSet` will not be evaluated until you need the resources data, 32 | i.e. it does not make any HTTP call until needed, to help express more complex queries 33 | using method chaining like in the following example: 34 | 35 | ```python 36 | products = client.products.filter(status='published').order_by('-created') 37 | ``` 38 | 39 | ## Count results 40 | 41 | To get the total number of resources represented by a `ResourceSet` you can use 42 | the `ResourceSet.count()` method. 43 | 44 | ```python 45 | no_of_published = client.products.filter(status='published').count() 46 | ``` 47 | 48 | or 49 | 50 | ```python 51 | total = client.products.all().count() 52 | ``` 53 | 54 | ## First result 55 | 56 | To get the first resource represented by a `ResourceSet` you can use 57 | the `ResourceSet.first()` method. 58 | 59 | ```python 60 | first = client.products.all().first() 61 | ``` 62 | 63 | or 64 | 65 | ```python 66 | first = client.products.filter(status='published').first() 67 | ``` 68 | 69 | ## Filtering resources 70 | 71 | The `ResourceSet` object offers three way to define 72 | your RQL query filters: 73 | 74 | ### Using raw RQL filter expressions 75 | 76 | You can express your filters using raw RQL expressions like in this example: 77 | 78 | ```python 79 | products = client.products.filter('ilike(name,*awesome*)', 'in(status,(draft,published))') 80 | ``` 81 | 82 | Arguments will be joined using the `and` logical operator. 83 | 84 | ### Using kwargs and the `__` (double underscore) notation 85 | 86 | You can use the `__` notation at the end of the name of the keyword argument 87 | to specify which RQL operator to apply: 88 | 89 | ```python 90 | products = client.products.filter(name__ilike='*awesome*', status__in=('draft', 'published')) 91 | ``` 92 | 93 | The lookups expressed through keyword arguments are `and`-ed togheter. 94 | 95 | Chaning the filter method combine filters using `and`. Equivalent to the previous 96 | expression is to write: 97 | 98 | ```python 99 | products = client.products.filter(name__ilike='*awesome*').filter(status__in=('draft', 'published')) 100 | ``` 101 | 102 | The `__` notation allow also to specify nested fields for lookups like: 103 | 104 | ```python 105 | products = client.products.filter(product__category__name__ilike='"*saas services*"') 106 | ``` 107 | 108 | ### Using the `R` object 109 | 110 | The `R` object allows to create complex RQL filter expression. 111 | 112 | The `R` constructor allows to specify lookups as keyword arguments 113 | the same way you do with the `filter()` method. 114 | 115 | But it allows also to specify nested fields using the `.` notation: 116 | 117 | ```python 118 | flt = R().product.category.name.ilike('"*saas services*"') 119 | 120 | products = client.products.filter(flt) 121 | ``` 122 | 123 | So an expression like: 124 | 125 | ```python 126 | flt = R().product.category.name.ilike('"*saas services*"') 127 | 128 | products = client.products.filter(flt, status__in=('draft', 'published')) 129 | ``` 130 | 131 | will result in the following RQL query: 132 | 133 | ``` 134 | and(ilike(product.category.name,"*saas services*"),in(status,(draft,published))) 135 | ``` 136 | 137 | The `R` object also allows to join filter expressions using logical `and` and `or` and `not` 138 | using the `&`, `|` and and `~` bitwise operators: 139 | 140 | ```python 141 | query = ( 142 | R(status='published') | R().category.name.ilike('*awesome*') 143 | ) & ~R(description__empty=True) 144 | ``` 145 | 146 | ## Other RQL operators 147 | 148 | ### Searching 149 | 150 | For endpoints that supports the RQL search operator you can specify 151 | your search term has shown below: 152 | 153 | ```python 154 | with_search = rs.search('term') 155 | ``` 156 | 157 | ### Ordering 158 | 159 | To apply ordering you can specify the fields that have to be used to order the results: 160 | 161 | ```python 162 | ordered = rs.order_by('+field1', '-field2') 163 | ``` 164 | 165 | Any subsequent calls append other fields to the previous one. 166 | 167 | So the previous statement can also be expressed with chaining: 168 | 169 | ```python 170 | ordered = rs.order_by('+field1').order_by('-field2') 171 | ``` 172 | 173 | ### RQL `select` 174 | 175 | For collections that supports the `select` RQL operator you can 176 | specify the object to be selected/unselected the following way: 177 | 178 | ```python 179 | with_select = rs.select('+object1', '-object2') 180 | ``` 181 | 182 | Any subsequent calls append other select expression to the previous. 183 | 184 | So the previous statement can also be expressed with chaining: 185 | 186 | ```python 187 | with_select = rs.select('+object1').select('-object2') 188 | ``` 189 | -------------------------------------------------------------------------------- /docs/r_object.md: -------------------------------------------------------------------------------- 1 | ## R 2 | 3 | ::: connect.client.R 4 | options: 5 | heading_level: 3 6 | 7 | ::: connect.client.rql.base.RQLQuery 8 | options: 9 | heading_level: 3 10 | -------------------------------------------------------------------------------- /docs/sync_client.md: -------------------------------------------------------------------------------- 1 | # Synchronous client 2 | 3 | ## ConnectClient 4 | 5 | ::: connect.client.ConnectClient 6 | options: 7 | heading_level: 3 8 | 9 | ::: connect.client.fluent._ConnectClientBase 10 | options: 11 | heading_level: 3 12 | 13 | ::: connect.client.mixins.SyncClientMixin 14 | options: 15 | heading_level: 3 16 | 17 | ## NS 18 | 19 | A **namespace** groups together a set of [**collections**](#collection) of [**resources**](#resource). 20 | 21 | ::: connect.client.models.base.NS 22 | options: 23 | heading_level: 3 24 | 25 | ::: connect.client.models.base._NSBase 26 | options: 27 | heading_level: 3 28 | 29 | ## Collection 30 | 31 | A **collection** is a set of [**resources**](#resource) of the same type. 32 | 33 | 34 | ::: connect.client.models.Collection 35 | options: 36 | heading_level: 3 37 | 38 | ::: connect.client.models.base._CollectionBase 39 | options: 40 | heading_level: 3 41 | 42 | ::: connect.client.models.mixins.CollectionMixin 43 | options: 44 | heading_level: 3 45 | 46 | ## Resource 47 | 48 | A **resource** is an object with a type, associated data, relationships to other resources 49 | and [**actions**](#action) that can be performed on such resource. 50 | 51 | ::: connect.client.models.Resource 52 | options: 53 | heading_level: 3 54 | 55 | ::: connect.client.models.base._ResourceBase 56 | options: 57 | heading_level: 3 58 | 59 | ::: connect.client.models.mixins.ResourceMixin 60 | options: 61 | heading_level: 3 62 | 63 | ## Action 64 | 65 | An **action** is an operation that can be performed on [**resources**](#resource) 66 | or [**collections**](#collection). 67 | 68 | ::: connect.client.models.Action 69 | options: 70 | heading_level: 3 71 | 72 | ::: connect.client.models.mixins.ActionMixin 73 | options: 74 | heading_level: 3 75 | 76 | ## ResourceSet 77 | 78 | A **ResourceSet** is a set of resources from one collection eventually filtered and ordered. 79 | 80 | ::: connect.client.models.ResourceSet 81 | options: 82 | heading_level: 3 83 | 84 | ::: connect.client.models.resourceset._ResourceSetBase 85 | options: 86 | heading_level: 3 -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | Testing tools 2 | ============= 3 | 4 | To make it easier to write unit tests for functions that use the 5 | ConnectClient, two utility classes are available in the 6 | connect.client.testing module: 7 | 8 | - `ConnectClientMocker` to mock http calls for the `ConnectClient` 9 | - `AsyncConnectClientMocker` to mock http calls for the 10 | `AsyncConnectClient` 11 | 12 | Usage example 13 | ------------- 14 | 15 | The `ConnectClientMocker` (or the `AsyncConnectClientMocker` for the 16 | `AsyncConnectClient`) folow the same fluent interface as the 17 | `ConnectClient`: 18 | 19 | ``` {.python} 20 | from connect.client import ConnectClient 21 | from connect.client.testing import ConnectClientMocker 22 | 23 | def test_get_all_products(): 24 | client = ConnectClient('ApiKey SU-000-000-000:xxxxxxxxxxxxxxxx') 25 | 26 | expected_response = [{'id': 'PRD-000'}] 27 | 28 | with ConnectClientMocker(client.endpoint) as mocker: 29 | mocker.products.all().mock(return_value=expected_response) 30 | 31 | assert list(client.products.all()) == expected_response 32 | ``` 33 | 34 | or if you use the `AsyncConnectClient`: 35 | 36 | ``` {.python} 37 | from connect.client import AsyncConnectClient 38 | from connect.client.testing import AsyncConnectClientMocker 39 | 40 | async def test_get_all_products(): 41 | client = AsyncConnectClient('ApiKey SU-000-000-000:xxxxxxxxxxxxxxxx') 42 | 43 | expected_response = [{'id': 'PRD-000'}] 44 | 45 | with AsyncConnectClientMocker(client.endpoint) as mocker: 46 | mocker.products.all().mock(return_value=expected_response) 47 | 48 | assert [item async for item in client.products.all()] == expected_response 49 | ``` 50 | 51 | Both the `ConnectClientMocker` and the `AsyncConnectClientMocker` constructors 52 | accept an extra keywork argument `exclude` to exclude urls from mocking. 53 | the exclude argument can be a string, a `re.Pattern` object or a list, tuple or set which elements 54 | can be strings or `re.Pattern`. 55 | 56 | Both mockers are also available as pytest fixtures: 57 | 58 | ``` {.python} 59 | def test_get_all_products(client_mocker_factory): 60 | client = ConnectClient('ApiKey SU-000-000-000:xxxxxxxxxxxxxxxx') 61 | 62 | expected_response = [{'id': 'PRD-000'}] 63 | 64 | mocker = client_mocker_factory(base_url=client.endpoint) 65 | mocker.products.all().mock(return_value=expected_response) 66 | 67 | assert list(client.products.all()) == expected_response 68 | ``` 69 | 70 | Also the fixtures accept an extra keyword argument `exclude` that is passed 71 | to the underlying mocker constructor. 72 | 73 | For more example on how to use the client mocker see the 74 | `tests/client/test_testing.py` file in the github repository. 75 | -------------------------------------------------------------------------------- /docs/working_with_resources.md: -------------------------------------------------------------------------------- 1 | ## Create a new resource 2 | 3 | To create a new resource inside a collection you can invoke the 4 | `create()` method on the corresponding 5 | `Collection` instance: 6 | 7 | ```python 8 | payload = { 9 | 'name': 'My Awesome Product', 10 | 'category': { 11 | 'id': 'CAT-00000', 12 | }, 13 | } 14 | 15 | new_product = c.products.create(payload=payload) 16 | ``` 17 | 18 | This returns the newly created object json-decoded. 19 | 20 | ## Access to a resource 21 | 22 | To access a resource within a collection you can use the 23 | `resource()` method on the corresponding 24 | `Collection` instance: 25 | 26 | ```python 27 | product = client.products.resource('PRD-000-000-000') 28 | ``` 29 | 30 | The indexing operator allows to write the previous expression the following way: 31 | 32 | ```python 33 | product = client.products['PRD-000-000-000'] 34 | ``` 35 | 36 | The previous expression returns a `Resource` object. 37 | 38 | ## Retrieve a resource 39 | 40 | To retrieve a resource from within a collection you have to invoke 41 | the `get()` method of the 42 | `Resource` object as shown below: 43 | 44 | ```python 45 | product = client.products['PRD-000-000-000'].get() 46 | ``` 47 | 48 | This call returns the json-decoded object or raise an exception 49 | if it does not exist. 50 | 51 | ## Update a resource 52 | 53 | To update a resource of the collection using its primary identifier, 54 | you can invoke the `update()` method as shown below: 55 | 56 | ```python 57 | payload = { 58 | 'short_description': 'This is the short description', 59 | 'detailed_description': 'This is the detailed description', 60 | } 61 | 62 | product = client.products['PRD-000-000-000'].update(payload=payload) 63 | ``` 64 | 65 | ## Delete a resource 66 | 67 | To delete a resource the `delete()` method is exposed: 68 | 69 | ```python 70 | client.products['PRD-000-000-000'].delete() 71 | ``` 72 | 73 | ## Access to an action 74 | 75 | To access an action that can be performed on a resource you can use 76 | the `action()` method of the 77 | `Resource` object: 78 | 79 | ```python 80 | endsale_action = client.products['PRD-000-000-000'].action('endsale') 81 | ``` 82 | 83 | or directly using the call operator 84 | on the `Resource` class passing the name of the action: 85 | 86 | ```python 87 | endsale_action = client.products['PRD-000-000-000']('endsale') 88 | ``` 89 | 90 | This returns an `Action` object. 91 | 92 | ## Execute an action on a resource 93 | 94 | Depending on its nature, an action can be exposed using the HTTP method that 95 | best gives the sense of the action to perform. 96 | The `Action` object exposes the 97 | `get()`, `post()`, 98 | `put()`, and `delete()` 99 | methods. 100 | 101 | For example, supose you want to execute the **endsale** action: 102 | 103 | ```python 104 | payload = { 105 | 'replacement': { 106 | 'id': 'PRD-111-111-111' 107 | }, 108 | 'end_of_sale_notes': 'stopped manufacturing', 109 | } 110 | 111 | result = client.products['PRD-000-000-000']('endsale').post(payload=payload) 112 | ``` -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Connect Python OpenAPI Client 2 | site_url: https://github.com/cloudblue/connect-python-openapi-client 3 | repo_name: cloudblue/connect-python-openapi-client 4 | repo_url: https://github.com/cloudblue/connect-python-openapi-client 5 | edit_uri: "" 6 | copyright: Copyright © 2023 Ingram Micro. All Rights Reserved. 7 | extra: 8 | generator: false 9 | extra_css: 10 | - css/custom.css 11 | theme: 12 | name: 'material' 13 | logo: logo_full.png 14 | favicon: favicon.ico 15 | palette: 16 | - scheme: 'default' 17 | media: '(prefers-color-scheme: light)' 18 | toggle: 19 | icon: 'material/lightbulb' 20 | name: "Switch to dark mode" 21 | - scheme: 'slate' 22 | media: '(prefers-color-scheme: dark)' 23 | primary: 'blue' 24 | toggle: 25 | icon: 'material/lightbulb-outline' 26 | name: 'Switch to light mode' 27 | markdown_extensions: 28 | - admonition 29 | - pymdownx.highlight 30 | - pymdownx.superfences 31 | - pymdownx.tabbed: 32 | alternate_style: true 33 | plugins: 34 | - mkdocstrings: 35 | default_handler: python 36 | handlers: 37 | python: 38 | options: 39 | show_signature_annotations: true 40 | show_source: false 41 | show_bases: false 42 | show_root_toc_entry: false 43 | watch: 44 | - connect/client 45 | - cnct 46 | - docs 47 | nav: 48 | - Home: index.md 49 | - User guide: 50 | - Getting started: getting_started.md 51 | - Namespaces and collections: namespaces_and_collections.md 52 | - Working with resources: working_with_resources.md 53 | - Querying collections: querying_collections.md 54 | - Testing tools: testing.md 55 | - API reference: 56 | - Synchronous client: sync_client.md 57 | - Asynchronous client: async_client.md 58 | - R object: r_object.md 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "connect-openapi-client" 3 | version = "29.5" 4 | description = "Connect Python OpenAPI Client" 5 | authors = ["CloudBlue"] 6 | license = "Apache-2.0" 7 | packages = [ 8 | { include = "connect" }, 9 | { include = "cnct" } 10 | ] 11 | readme = "./README.md" 12 | documentation = "https://connect-openapi-client.readthedocs.io/en/latest/" 13 | homepage = "https://connect.cloudblue.com" 14 | repository = "https://github.com/cloudblue/connect-python-openapi-client" 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Environment :: Console", 18 | "Operating System :: OS Independent", 19 | "Intended Audience :: Developers", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Topic :: Utilities", 25 | "Topic :: Software Development :: Libraries", 26 | ] 27 | keywords = [ 28 | "fulfillment", 29 | "api", 30 | "client", 31 | "openapi", 32 | "utility", 33 | "connect", 34 | "cloudblue", 35 | ] 36 | 37 | [tool.poetry.dependencies] 38 | python = ">=3.9,<4" 39 | connect-markdown-renderer = "^3" 40 | PyYAML = ">=5.3.1" 41 | requests = ">=2.23" 42 | inflect = ">=4.1" 43 | httpx = ">=0.23" 44 | asgiref = "^3.3.4" 45 | responses = ">=0.14.0,<1" 46 | pytest-httpx = ">=0.27,<0.30" 47 | importlib-metadata = "^6.6" 48 | 49 | [tool.poetry.group.test.dependencies] 50 | black = "23.*" 51 | pytest = ">=6.1.2,<8" 52 | pytest-cov = ">=2.10.1,<5" 53 | pytest-mock = "^3.10" 54 | coverage = {extras = ["toml"], version = ">=5.3,<7"} 55 | flake8 = ">=6" 56 | flake8-black = "0.*" 57 | flake8-bugbear = ">=20,<23" 58 | flake8-cognitive-complexity = "^0.1" 59 | flake8-commas = "~4" 60 | flake8-future-import = "~0.4" 61 | flake8-import-order = ">=0.18.2" 62 | flake8-isort = "^6.0" 63 | flake8-broken-line = ">=1.0" 64 | flake8-pyproject = "^1.2.3" 65 | isort = "^5.10" 66 | pytest-asyncio = "^0.15.1" 67 | 68 | [tool.poetry.group.docs.dependencies] 69 | mkdocs = "^1.3.1" 70 | mkdocs-material = "^8.5.3" 71 | mkdocstrings = "^0.20.0" 72 | mkdocstrings-python = "^0.8.3" 73 | 74 | [tool.poetry.plugins.pytest11] 75 | "pytest_connect_client" = "connect.client.testing.fixtures" 76 | 77 | [build-system] 78 | requires = ["poetry-core>=1.0.0", "setuptools>=42"] 79 | build-backend = "poetry.core.masonry.api" 80 | 81 | [tool.pytest.ini_options] 82 | testpaths = "tests" 83 | addopts = "--cov=connect.client --cov-report=term-missing:skip-covered --cov-report=html --cov-report=xml" 84 | log_cli = false 85 | log_cli_level = "INFO" 86 | 87 | [tool.coverage.run] 88 | branch = true 89 | 90 | [tool.coverage.report] 91 | omit = [ 92 | "*/migrations/*", 93 | "*/config/*", 94 | "*/settings/*", 95 | "*/manage.py", 96 | "*/wsgi.py", 97 | "*/urls.py", 98 | "connect/client/contrib/*" 99 | ] 100 | 101 | exclude_lines = [ 102 | "pragma: no cover", 103 | "def __str__", 104 | "def __repr__", 105 | "raise NotImplementedError", 106 | "if __name__ == .__main__.:", 107 | ] 108 | 109 | [tool.isort] 110 | src_paths = "*" 111 | sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 112 | group_by_package = true 113 | multi_line_output = 3 114 | force_grid_wrap = 4 115 | combine_as_imports = true 116 | use_parentheses = true 117 | include_trailing_comma = true 118 | line_length = 100 119 | lines_after_imports = 2 120 | 121 | [tool.flake8] 122 | exclude = [ 123 | ".idea", 124 | ".vscode", 125 | ".git", 126 | "pg_data", 127 | "venv", 128 | "*.eggs", 129 | "*.egg", 130 | "tests/fixtures", 131 | "setup.py", 132 | "resources", 133 | "docs/*", 134 | ] 135 | show-source = true 136 | max-line-length = 100 137 | max-cognitive-complexity = 15 138 | select = "B" 139 | ignore = ["FI1", "W503"] 140 | 141 | 142 | [tool.black] 143 | line_length = 100 144 | skip-string-normalization = true 145 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | anyio==4.4.0 ; python_version >= "3.9" and python_version < "4" 2 | asgiref==3.8.1 ; python_version >= "3.9" and python_version < "4" 3 | certifi==2024.6.2 ; python_version >= "3.9" and python_version < "4" 4 | charset-normalizer==3.3.2 ; python_version >= "3.9" and python_version < "4" 5 | click==8.1.7 ; python_version >= "3.9" and python_version < "4" 6 | colorama==0.4.6 ; python_version >= "3.9" and python_version < "4" 7 | commonmark==0.9.1 ; python_version >= "3.9" and python_version < "4" 8 | connect-markdown-renderer==3.0.0 ; python_version >= "3.9" and python_version < "4" 9 | exceptiongroup==1.2.1 ; python_version >= "3.9" and python_version < "3.11" 10 | ghp-import==2.1.0 ; python_version >= "3.9" and python_version < "4" 11 | griffe==0.45.2 ; python_version >= "3.9" and python_version < "4" 12 | h11==0.14.0 ; python_version >= "3.9" and python_version < "4" 13 | httpcore==1.0.5 ; python_version >= "3.9" and python_version < "4" 14 | httpx==0.27.0 ; python_version >= "3.9" and python_version < "4" 15 | idna==3.7 ; python_version >= "3.9" and python_version < "4" 16 | importlib-metadata==6.11.0 ; python_version >= "3.9" and python_version < "4" 17 | inflect==7.2.1 ; python_version >= "3.9" and python_version < "4" 18 | iniconfig==2.0.0 ; python_version >= "3.9" and python_version < "4" 19 | jinja2==3.1.4 ; python_version >= "3.9" and python_version < "4" 20 | markdown-it-py==2.2.0 ; python_version >= "3.9" and python_version < "4" 21 | markdown==3.6 ; python_version >= "3.9" and python_version < "4" 22 | markupsafe==2.1.5 ; python_version >= "3.9" and python_version < "4" 23 | mdurl==0.1.2 ; python_version >= "3.9" and python_version < "4" 24 | mergedeep==1.3.4 ; python_version >= "3.9" and python_version < "4" 25 | mkdocs-autorefs==1.0.1 ; python_version >= "3.9" and python_version < "4" 26 | mkdocs-get-deps==0.2.0 ; python_version >= "3.9" and python_version < "4" 27 | mkdocs-material-extensions==1.3.1 ; python_version >= "3.9" and python_version < "4" 28 | mkdocs-material==8.5.11 ; python_version >= "3.9" and python_version < "4" 29 | mkdocs==1.6.0 ; python_version >= "3.9" and python_version < "4" 30 | mkdocstrings-python==0.8.3 ; python_version >= "3.9" and python_version < "4" 31 | mkdocstrings==0.20.0 ; python_version >= "3.9" and python_version < "4" 32 | more-itertools==10.2.0 ; python_version >= "3.9" and python_version < "4" 33 | packaging==24.0 ; python_version >= "3.9" and python_version < "4" 34 | pathspec==0.12.1 ; python_version >= "3.9" and python_version < "4" 35 | platformdirs==4.2.2 ; python_version >= "3.9" and python_version < "4" 36 | pluggy==1.5.0 ; python_version >= "3.9" and python_version < "4" 37 | pygments==2.18.0 ; python_version >= "3.9" and python_version < "4" 38 | pymdown-extensions==10.8.1 ; python_version >= "3.9" and python_version < "4" 39 | pytest-httpx==0.30.0 ; python_version >= "3.9" and python_version < "4" 40 | pytest==7.4.4 ; python_version >= "3.9" and python_version < "4" 41 | python-dateutil==2.9.0.post0 ; python_version >= "3.9" and python_version < "4" 42 | pyyaml-env-tag==0.1 ; python_version >= "3.9" and python_version < "4" 43 | pyyaml==6.0.1 ; python_version >= "3.9" and python_version < "4" 44 | requests==2.32.3 ; python_version >= "3.9" and python_version < "4" 45 | responses==0.25.0 ; python_version >= "3.9" and python_version < "4" 46 | rich==12.6.0 ; python_version >= "3.9" and python_version < "4" 47 | six==1.16.0 ; python_version >= "3.9" and python_version < "4" 48 | sniffio==1.3.1 ; python_version >= "3.9" and python_version < "4" 49 | tomli==2.0.1 ; python_version >= "3.9" and python_version < "3.11" 50 | typeguard==4.3.0 ; python_version >= "3.9" and python_version < "4" 51 | typing-extensions==4.12.1 ; python_version >= "3.9" and python_version < "4" 52 | urllib3==1.26.18 ; python_version >= "3.9" and python_version < "4" 53 | watchdog==4.0.1 ; python_version >= "3.9" and python_version < "4" 54 | zipp==3.19.1 ; python_version >= "3.9" and python_version < "4" 55 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectName=Connect Python OpenAPI Client 2 | sonar.projectKey=connect-open-api-client 3 | sonar.organization=cloudbluesonarcube 4 | 5 | sonar.language=py 6 | 7 | sonar.sources=connect 8 | sonar.tests=tests 9 | sonar.inclusions=connect/** 10 | sonar.exclusions=connect/client/contrib/**,tests/** 11 | 12 | sonar.python.coverage.reportPaths=./coverage.xml 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudblue/connect-python-openapi-client/dbbb0385baadb52e85d8d504b2c29d934ad577eb/tests/__init__.py -------------------------------------------------------------------------------- /tests/async_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudblue/connect-python-openapi-client/dbbb0385baadb52e85d8d504b2c29d934ad577eb/tests/async_client/__init__.py -------------------------------------------------------------------------------- /tests/async_client/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | 4 | from connect.client import AsyncConnectClient 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_client_mocker_factory(async_client_mocker_factory): 9 | mocker = async_client_mocker_factory('http://example.com') 10 | mocker.products.create(return_value={'id': 'PRD-000'}) 11 | 12 | client = AsyncConnectClient('api_key', endpoint='http://example.com') 13 | assert await client.products.create(payload={}) == {'id': 'PRD-000'} 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_httpx_mocker(async_client_mocker_factory, httpx_mocker): 18 | mocker = async_client_mocker_factory('http://example.com') 19 | mocker.products.create(return_value={'id': 'PRD-000'}) 20 | httpx_mocker.add_response( 21 | method='GET', 22 | url='https://test.com', 23 | json=[{'key1': 'value1', 'key2': 'value2'}], 24 | ) 25 | 26 | client = AsyncConnectClient('api_key', endpoint='http://example.com') 27 | assert await client.products.create(payload={}) == {'id': 'PRD-000'} 28 | 29 | async with httpx.AsyncClient() as client: 30 | response = await client.get('https://test.com') 31 | assert response.json() == [{'key1': 'value1', 'key2': 'value2'}] 32 | -------------------------------------------------------------------------------- /tests/async_client/test_fluent.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | 4 | import pytest 5 | 6 | from connect.client import AsyncConnectClient, ClientError 7 | from connect.client.logger import RequestLogger 8 | from connect.client.models import AsyncCollection, AsyncNS 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_async_get(async_mocker): 13 | url = 'https://localhost' 14 | kwargs = { 15 | 'arg1': 'val1', 16 | } 17 | c = AsyncConnectClient('API_KEY') 18 | mocked = async_mocker.AsyncMock() 19 | c.execute = mocked 20 | await c.get(url, **kwargs) 21 | mocked.assert_awaited_once_with('get', url, **kwargs) 22 | 23 | 24 | @pytest.mark.asyncio 25 | @pytest.mark.parametrize('attr', ('payload', 'json')) 26 | async def test_create(async_mocker, attr): 27 | mocked = async_mocker.AsyncMock() 28 | url = 'https://localhost' 29 | kwargs = { 30 | 'arg1': 'val1', 31 | } 32 | kwargs[attr] = {'k1': 'v1'} 33 | 34 | c = AsyncConnectClient('API_KEY') 35 | c.execute = mocked 36 | 37 | await c.create(url, **kwargs) 38 | 39 | mocked.assert_awaited_once_with( 40 | 'post', 41 | url, 42 | **{ 43 | 'arg1': 'val1', 44 | 'json': { 45 | 'k1': 'v1', 46 | }, 47 | }, 48 | ) 49 | 50 | 51 | @pytest.mark.asyncio 52 | @pytest.mark.parametrize('attr', ('payload', 'json')) 53 | async def test_update(async_mocker, attr): 54 | mocked = async_mocker.AsyncMock() 55 | url = 'https://localhost' 56 | kwargs = { 57 | 'arg1': 'val1', 58 | } 59 | kwargs[attr] = {'k1': 'v1'} 60 | 61 | c = AsyncConnectClient('API_KEY') 62 | c.execute = mocked 63 | 64 | await c.update(url, **kwargs) 65 | 66 | mocked.assert_awaited_once_with( 67 | 'put', 68 | url, 69 | **{ 70 | 'arg1': 'val1', 71 | 'json': { 72 | 'k1': 'v1', 73 | }, 74 | }, 75 | ) 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_delete_no_args(async_mocker): 80 | mocked = async_mocker.AsyncMock() 81 | url = 'https://localhost' 82 | 83 | c = AsyncConnectClient('API_KEY') 84 | c.execute = mocked 85 | 86 | await c.delete(url) 87 | 88 | mocked.assert_awaited_once_with('delete', url) 89 | 90 | 91 | @pytest.mark.asyncio 92 | @pytest.mark.parametrize('attr', ('payload', 'json')) 93 | async def test_delete(async_mocker, attr): 94 | mocked = async_mocker.AsyncMock() 95 | url = 'https://localhost' 96 | 97 | kwargs = { 98 | 'arg1': 'val1', 99 | } 100 | kwargs[attr] = {'k1': 'v1'} 101 | 102 | c = AsyncConnectClient('API_KEY') 103 | c.execute = mocked 104 | 105 | await c.delete(url, **kwargs) 106 | 107 | mocked.assert_awaited_once_with( 108 | 'delete', 109 | url, 110 | **{ 111 | 'arg1': 'val1', 112 | 'json': { 113 | 'k1': 'v1', 114 | }, 115 | }, 116 | ) 117 | 118 | 119 | @pytest.mark.asyncio 120 | async def test_execute(httpx_mock): 121 | expected = [{'id': i} for i in range(10)] 122 | httpx_mock.add_response( 123 | method='GET', 124 | url='https://localhost/resources', 125 | json=expected, 126 | ) 127 | 128 | ios = io.StringIO() 129 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost', logger=RequestLogger(file=ios)) 130 | 131 | results = await c.execute('get', 'resources') 132 | 133 | assert httpx_mock.get_requests()[0].method == 'GET' 134 | headers = httpx_mock.get_requests()[0].headers 135 | 136 | assert 'Authorization' in headers and headers['Authorization'] == 'API_KEY' 137 | assert 'User-Agent' in headers and headers['User-Agent'].startswith('connect-fluent') 138 | 139 | assert results == expected 140 | 141 | 142 | @pytest.mark.asyncio 143 | async def test_execute_validate_with_specs(async_mocker): 144 | mocked_specs = async_mocker.MagicMock() 145 | mocked_specs.exists.return_value = False 146 | 147 | c = AsyncConnectClient('API_KEY') 148 | c.specs = mocked_specs 149 | c._use_specs = True 150 | with pytest.raises(ClientError) as cv: 151 | await c.execute('GET', 'resources') 152 | 153 | assert str(cv.value) == 'The path `resources` does not exist.' 154 | 155 | 156 | @pytest.mark.asyncio 157 | async def test_execute_non_json_response(httpx_mock): 158 | httpx_mock.add_response( 159 | method='GET', 160 | url='https://localhost/resources', 161 | status_code=200, 162 | text='This is a non json response.', 163 | ) 164 | c = AsyncConnectClient( 165 | 'API_KEY', 166 | endpoint='https://localhost', 167 | ) 168 | result = await c.execute('get', 'resources') 169 | assert result == b'This is a non json response.' 170 | 171 | 172 | @pytest.mark.asyncio 173 | async def test_execute_retries(httpx_mock): 174 | expected = [{'id': i} for i in range(10)] 175 | httpx_mock.add_response( 176 | method='GET', 177 | url='https://localhost/resources', 178 | status_code=502, 179 | ) 180 | 181 | httpx_mock.add_response( 182 | method='GET', 183 | url='https://localhost/resources', 184 | status_code=502, 185 | ) 186 | 187 | httpx_mock.add_response( 188 | method='GET', 189 | url='https://localhost/resources', 190 | status_code=200, 191 | json=expected, 192 | ) 193 | 194 | c = AsyncConnectClient( 195 | 'API_KEY', 196 | endpoint='https://localhost', 197 | max_retries=2, 198 | ) 199 | 200 | results = await c.execute('get', 'resources') 201 | 202 | assert httpx_mock.get_requests()[0].method == 'GET' 203 | headers = httpx_mock.get_requests()[0].headers 204 | 205 | assert 'Authorization' in headers and headers['Authorization'] == 'API_KEY' 206 | assert 'User-Agent' in headers and headers['User-Agent'].startswith('connect-fluent') 207 | 208 | assert results == expected 209 | 210 | 211 | @pytest.mark.asyncio 212 | async def test_execute_max_retries_exceeded(httpx_mock): 213 | httpx_mock.add_response( 214 | method='GET', 215 | url='https://localhost/resources', 216 | status_code=502, 217 | ) 218 | httpx_mock.add_response( 219 | method='GET', 220 | url='https://localhost/resources', 221 | status_code=502, 222 | ) 223 | httpx_mock.add_response( 224 | method='GET', 225 | url='https://localhost/resources', 226 | status_code=502, 227 | ) 228 | 229 | c = AsyncConnectClient( 230 | 'API_KEY', 231 | endpoint='https://localhost', 232 | max_retries=2, 233 | ) 234 | 235 | with pytest.raises(ClientError): 236 | await c.execute('get', 'resources') 237 | 238 | 239 | @pytest.mark.asyncio 240 | async def test_execute_default_headers(httpx_mock): 241 | httpx_mock.add_response( 242 | method='GET', 243 | url='https://localhost/resources', 244 | json=[], 245 | ) 246 | 247 | c = AsyncConnectClient( 248 | 'API_KEY', 249 | endpoint='https://localhost', 250 | default_headers={'X-Custom-Header': 'custom-header-value'}, 251 | ) 252 | 253 | await c.execute('get', 'resources') 254 | 255 | headers = httpx_mock.get_requests()[0].headers 256 | 257 | assert 'Authorization' in headers and headers['Authorization'] == 'API_KEY' 258 | assert 'User-Agent' in headers and headers['User-Agent'].startswith('connect-fluent') 259 | assert 'X-Custom-Header' in headers and headers['X-Custom-Header'] == 'custom-header-value' 260 | 261 | 262 | @pytest.mark.asyncio 263 | async def test_execute_with_kwargs(httpx_mock): 264 | httpx_mock.add_response( 265 | method='POST', 266 | url='https://localhost/resources', 267 | json=[], 268 | status_code=201, 269 | ) 270 | 271 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 272 | kwargs = { 273 | 'headers': { 274 | 'X-Custom-Header': 'value', 275 | }, 276 | } 277 | 278 | await c.execute('post', 'resources', **kwargs) 279 | 280 | assert httpx_mock.get_requests()[0].method == 'POST' 281 | 282 | headers = httpx_mock.get_requests()[0].headers 283 | 284 | assert 'Authorization' in headers and headers['Authorization'] == 'API_KEY' 285 | assert 'User-Agent' in headers and headers['User-Agent'].startswith('connect-fluent') 286 | assert 'X-Custom-Header' in headers and headers['X-Custom-Header'] == 'value' 287 | 288 | 289 | @pytest.mark.asyncio 290 | async def test_execute_connect_error(httpx_mock): 291 | connect_error = { 292 | 'error_code': 'code', 293 | 'errors': ['first', 'second'], 294 | } 295 | 296 | httpx_mock.add_response( 297 | method='POST', 298 | url='https://localhost/resources', 299 | json=connect_error, 300 | status_code=400, 301 | ) 302 | 303 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 304 | 305 | with pytest.raises(ClientError) as cv: 306 | await c.execute('post', 'resources') 307 | 308 | assert cv.value.status_code == 400 309 | assert cv.value.error_code == 'code' 310 | assert cv.value.errors == ['first', 'second'] 311 | 312 | 313 | @pytest.mark.asyncio 314 | async def test_execute_unexpected_connect_error(httpx_mock): 315 | connect_error = { 316 | 'unrecognized': 'code', 317 | 'attributes': ['first', 'second'], 318 | } 319 | 320 | httpx_mock.add_response( 321 | method='POST', 322 | url='https://localhost/resources', 323 | json=connect_error, 324 | status_code=400, 325 | ) 326 | 327 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 328 | 329 | with pytest.raises(ClientError) as cv: 330 | await c.execute('post', 'resources') 331 | 332 | assert str(cv.value) == '400 Bad Request' 333 | 334 | 335 | @pytest.mark.asyncio 336 | async def test_execute_uparseable_connect_error(httpx_mock): 337 | httpx_mock.add_response( 338 | method='POST', 339 | url='https://localhost/resources', 340 | text='error text', 341 | status_code=400, 342 | ) 343 | 344 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 345 | 346 | with pytest.raises(ClientError): 347 | await c.execute('post', 'resources') 348 | 349 | 350 | @pytest.mark.asyncio 351 | @pytest.mark.parametrize('encoding', ('utf-8', 'iso-8859-1')) 352 | async def test_execute_error_with_reason(httpx_mock, encoding): 353 | httpx_mock.add_response( 354 | method='POST', 355 | url='https://localhost/resources', 356 | status_code=500, 357 | content='Internal Server Error'.encode(encoding), 358 | ) 359 | 360 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 361 | 362 | with pytest.raises(ClientError): 363 | await c.execute('post', 'resources') 364 | 365 | 366 | @pytest.mark.asyncio 367 | async def test_execute_delete(httpx_mock): 368 | httpx_mock.add_response( 369 | method='DELETE', 370 | url='https://localhost/resources', 371 | status_code=204, 372 | ) 373 | 374 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 375 | 376 | results = await c.execute('delete', 'resources') 377 | 378 | assert results is None 379 | 380 | 381 | def test_collection(): 382 | c = AsyncConnectClient('API_KEY') 383 | assert isinstance(c.collection('resources'), AsyncCollection) 384 | 385 | 386 | def test_ns(): 387 | c = AsyncConnectClient('API_KEY') 388 | assert isinstance(c.ns('namespace'), AsyncNS) 389 | 390 | 391 | @pytest.mark.asyncio 392 | async def test_execute_with_qs_and_params(httpx_mock): 393 | httpx_mock.add_response( 394 | method='GET', 395 | url='https://localhost/resources?eq(a,b)&limit=100&offset=0', 396 | json=[], 397 | status_code=201, 398 | ) 399 | 400 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 401 | kwargs = { 402 | 'params': { 403 | 'limit': 100, 404 | 'offset': 0, 405 | }, 406 | } 407 | 408 | await c.execute('get', 'resources?eq(a,b)', **kwargs) 409 | 410 | 411 | @pytest.mark.asyncio 412 | async def test_execute_with_params_only(httpx_mock): 413 | httpx_mock.add_response( 414 | method='GET', 415 | url='https://localhost/resources?limit=100&offset=0', 416 | json=[], 417 | status_code=201, 418 | ) 419 | 420 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 421 | kwargs = { 422 | 'params': { 423 | 'limit': 100, 424 | 'offset': 0, 425 | }, 426 | } 427 | 428 | await c.execute('get', 'resources', **kwargs) 429 | 430 | 431 | @pytest.mark.asyncio 432 | async def test_async_client_manage_response(): 433 | c = AsyncConnectClient('API_KEY') 434 | assert c.response is None 435 | c.response = 'Some response' 436 | assert c._response.get() == 'Some response' 437 | 438 | 439 | @pytest.mark.asyncio 440 | async def test_parallel_tasks(httpx_mock): 441 | for idx in range(100): 442 | httpx_mock.add_response( 443 | method='GET', 444 | url=f'https://localhost/resources/{idx}', 445 | json={'idx': idx}, 446 | status_code=200, 447 | ) 448 | 449 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 450 | 451 | async def fetcher(client, idx): 452 | obj = await client.resources[str(idx)].get() 453 | assert obj == {'idx': idx} 454 | 455 | asyncio.gather(*[asyncio.create_task(fetcher(c, idx)) for idx in range(100)]) 456 | 457 | 458 | @pytest.mark.asyncio 459 | async def test_concurrency(httpx_mock): 460 | httpx_mock.add_response( 461 | method='GET', 462 | url='https://localhost/resources?limit=1&offset=0', 463 | json=[{'id': 1}], 464 | status_code=200, 465 | ) 466 | 467 | httpx_mock.add_response( 468 | method='GET', 469 | url='https://localhost/resources?limit=1&offset=0', 470 | json=[{'id': 2}], 471 | status_code=200, 472 | ) 473 | 474 | async def io_func(client): 475 | resp = await client.resources.all().first() 476 | return resp, client.response, client.session 477 | 478 | c = AsyncConnectClient('API_KEY', endpoint='https://localhost') 479 | 480 | res1, resp1, ses1 = await asyncio.create_task(io_func(c)) 481 | res2, resp2, ses2 = await asyncio.create_task(io_func(c)) 482 | 483 | assert res1 != res2 484 | assert resp1.json() != resp2.json() 485 | assert ses1 != ses2 486 | assert ses1._transport == ses2._transport 487 | assert c.response is None 488 | -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudblue/connect-python-openapi-client/dbbb0385baadb52e85d8d504b2c29d934ad577eb/tests/client/__init__.py -------------------------------------------------------------------------------- /tests/client/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from connect.client.exceptions import ClientError 2 | 3 | 4 | def test_connect_error(): 5 | c = ClientError(status_code=400, error_code='error_code', errors=['msg1', 'msg2']) 6 | 7 | assert repr(c) == '' 8 | assert str(c) == '400 Bad Request: error_code - msg1,msg2' 9 | 10 | 11 | def test_connect_error_additional_info(): 12 | additional_info = { 13 | 'attr1': 'val1', 14 | 'attr2': 'val2', 15 | } 16 | 17 | c = ClientError( 18 | status_code=400, 19 | error_code='error_code', 20 | errors=['msg1', 'msg2'], 21 | **additional_info, 22 | ) 23 | assert c.additional_info == additional_info 24 | 25 | 26 | def test_connect_error_unexpected_error(): 27 | assert str(ClientError()) == 'Unexpected error' 28 | -------------------------------------------------------------------------------- /tests/client/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from connect.client import ClientError, ConnectClient 5 | 6 | 7 | def test_client_mocker_factory(client_mocker_factory): 8 | mocker = client_mocker_factory('http://example.com') 9 | mocker.products.create(return_value={'id': 'PRD-000'}) 10 | 11 | client = ConnectClient('api_key', endpoint='http://example.com') 12 | assert client.products.create(payload={}) == {'id': 'PRD-000'} 13 | 14 | 15 | def test_client_mocker_factory_finalizer(): 16 | client = ConnectClient('api_key', endpoint='http://example.com') 17 | with pytest.raises(ClientError): 18 | client.products.create(payload={}) 19 | 20 | 21 | def test_client_mocker_factory_default_base_url(client_mocker_factory): 22 | mocker = client_mocker_factory() 23 | mocker.products.create(return_value={'id': 'PRD-000'}) 24 | 25 | client = ConnectClient('api_key', endpoint='https://example.org/public/v1') 26 | assert client.products.create(payload={}) == {'id': 'PRD-000'} 27 | 28 | 29 | def test_requests_mocker(client_mocker_factory, requests_mocker): 30 | mocker = client_mocker_factory('http://example.com') 31 | mocker.products.create(return_value={'id': 'PRD-000'}) 32 | requests_mocker.get('https://test.com', json={'a': 'b'}) 33 | 34 | client = ConnectClient('api_key', endpoint='http://example.com') 35 | assert client.products.create(payload={}) == {'id': 'PRD-000'} 36 | assert requests.get('https://test.com').json() == {'a': 'b'} 37 | -------------------------------------------------------------------------------- /tests/client/test_help_formatter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from connect.client.help_formatter import DefaultFormatter 4 | 5 | 6 | def test_no_specs(): 7 | formatter = DefaultFormatter(None) 8 | formatted = formatter.format(None) 9 | assert 'No OpenAPI specs available.' in formatted 10 | 11 | 12 | def test_format_client(openapi_specs): 13 | formatter = DefaultFormatter(openapi_specs) 14 | formatted = formatter.format(None) 15 | assert openapi_specs.title in formatted 16 | assert openapi_specs.version in formatted 17 | 18 | 19 | @pytest.mark.parametrize( 20 | ('title', 'path'), 21 | ( 22 | ('Subscriptions', 'subscriptions'), 23 | ('Dictionary', 'dictionary'), 24 | ), 25 | ) 26 | def test_format_ns(openapi_specs, ns_factory, title, path): 27 | formatter = DefaultFormatter(openapi_specs) 28 | ns = ns_factory(path=path) 29 | formatted = formatter.format(ns) 30 | assert f'{title} namespace' in formatted 31 | assert f'path: /{path}' in formatted 32 | 33 | 34 | def test_format_ns_not_exist(openapi_specs, ns_factory): 35 | formatter = DefaultFormatter(openapi_specs) 36 | ns = ns_factory(path='does-not-exists') 37 | formatted = formatter.format(ns) 38 | assert 'does not exist' in formatted 39 | 40 | 41 | @pytest.mark.parametrize( 42 | ('title', 'path'), 43 | ( 44 | ('Products', 'products'), 45 | ('Assets', 'subscriptions/assets'), 46 | ('Items', 'products/PRD-000/items'), 47 | ), 48 | ) 49 | def test_format_collection(openapi_specs, col_factory, title, path): 50 | formatter = DefaultFormatter(openapi_specs) 51 | col = col_factory(path=path) 52 | formatted = formatter.format(col) 53 | assert f'{title} collection' in formatted 54 | assert f'path: /{path}' in formatted 55 | 56 | 57 | def test_format_collection_not_exists(openapi_specs, col_factory): 58 | formatter = DefaultFormatter(openapi_specs) 59 | col = col_factory(path='does-not-exists') 60 | formatted = formatter.format(col) 61 | assert 'does not exist' in formatted 62 | 63 | 64 | def test_format_resource(openapi_specs, res_factory): 65 | formatter = DefaultFormatter(openapi_specs) 66 | res = res_factory(path='products/PRD-000') 67 | formatted = formatter.format(res) 68 | assert 'Product resource' in formatted 69 | assert 'path: /products/PRD-000' in formatted 70 | 71 | res = res_factory(path='subscriptions/assets/AS-0000') 72 | formatted = formatter.format(res) 73 | assert 'Asset resource' in formatted 74 | assert 'path: /subscriptions/assets/AS-0000' in formatted 75 | 76 | res = res_factory(path='does-not-exists') 77 | formatted = formatter.format(res) 78 | assert 'does not exist' in formatted 79 | 80 | 81 | def test_format_action(openapi_specs, action_factory): 82 | formatter = DefaultFormatter(openapi_specs) 83 | action = action_factory(path='products/PRD-000/endsale') 84 | formatted = formatter.format(action) 85 | assert 'Endsale action' in formatted 86 | assert 'path: /products/PRD-000/endsale' in formatted 87 | 88 | action = action_factory(path='does-not-exists') 89 | formatted = formatter.format(action) 90 | assert 'does not exist' in formatted 91 | 92 | 93 | def test_format_rs(openapi_specs, rs_factory): 94 | formatter = DefaultFormatter(openapi_specs) 95 | rs = rs_factory(path='products') 96 | formatted = formatter.format(rs) 97 | assert 'Search the Products collection' in formatted 98 | assert 'path: /products' in formatted 99 | assert 'Available filters' in formatted 100 | 101 | rs = rs_factory(path='products/PRD-000/items') 102 | formatted = formatter.format(rs) 103 | assert 'Search the Items collection' in formatted 104 | assert 'path: /products/PRD-000/items' in formatted 105 | assert 'Available filters' in formatted 106 | 107 | rs = rs_factory(path='does-not-exists') 108 | formatted = formatter.format(rs) 109 | assert 'does not exist' in formatted 110 | -------------------------------------------------------------------------------- /tests/client/test_logger.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from requests.models import Response 4 | from urllib3.response import HTTPResponse 5 | 6 | from connect.client.logger import RequestLogger 7 | 8 | 9 | def test_log_request(): 10 | LOG_REQUEST_HEADER = '--- HTTP Request ---\n' 11 | PATH1 = 'https://some.host.name/some/path' 12 | PATH2 = 'https://some.host.name/some/path?a=b' 13 | 14 | ios = io.StringIO() 15 | rl = RequestLogger(file=ios) 16 | 17 | rl.log_request('get', PATH1, {}) 18 | assert ios.getvalue() == LOG_REQUEST_HEADER + 'GET ' + PATH1 + ' \n\n' 19 | 20 | ios.truncate(0) 21 | ios.seek(0, 0) 22 | rl.log_request( 23 | 'get', 24 | PATH1, 25 | {'headers': {'Auth': 'None', 'Cookie': 'XXX', 'Authorization': 'ApiKey SU-XXXX:YYYYY'}}, 26 | ) 27 | assert ( 28 | ios.getvalue() 29 | == LOG_REQUEST_HEADER 30 | + 'GET ' 31 | + PATH1 32 | + ' \n' 33 | + """Auth: None 34 | Cookie: XXX 35 | Authorization: ApiKey SU-XXXX********** 36 | 37 | """ 38 | ) 39 | 40 | ios.truncate(0) 41 | ios.seek(0, 0) 42 | rl.log_request( 43 | 'get', 44 | PATH1, 45 | { 46 | 'headers': { 47 | 'Auth': 'None', 48 | 'Cookie': '_ga=wathever; api_key="test@example.com:abcdefg"; _gid=whatever', 49 | 'Authorization': 'SecretToken', 50 | }, 51 | }, 52 | ) 53 | assert ( 54 | ios.getvalue() 55 | == LOG_REQUEST_HEADER 56 | + 'GET ' 57 | + PATH1 58 | + ' \n' 59 | + """Auth: None 60 | Cookie: _ga=wathever; api_key="te******fg"; _gid=whatever 61 | Authorization: ******************** 62 | 63 | """ 64 | ) 65 | 66 | ios.truncate(0) 67 | ios.seek(0, 0) 68 | rl.log_request('post', PATH1, {'json': {'id': 'XX-1234', 'name': 'XXX'}}) 69 | assert ( 70 | ios.getvalue() 71 | == LOG_REQUEST_HEADER 72 | + 'POST ' 73 | + PATH1 74 | + ' \n' 75 | + """{ 76 | "id": "XX-1234", 77 | "name": "XXX" 78 | } 79 | 80 | """ 81 | ) 82 | 83 | ios.truncate(0) 84 | ios.seek(0, 0) 85 | rl.log_request('get', PATH1, {'params': {'limit': 10, 'offset': 0}}) 86 | assert ios.getvalue() == LOG_REQUEST_HEADER + 'GET ' + PATH1 + '?limit=10&offset=0 \n\n' 87 | 88 | ios.truncate(0) 89 | ios.seek(0, 0) 90 | rl.log_request('get', PATH2, {}) 91 | assert ios.getvalue() == LOG_REQUEST_HEADER + 'GET ' + PATH2 + ' \n\n' 92 | 93 | ios.truncate(0) 94 | ios.seek(0, 0) 95 | rl.log_request('get', PATH2, {'params': {'limit': 10, 'offset': 0}}) 96 | assert ios.getvalue() == LOG_REQUEST_HEADER + 'GET ' + PATH2 + '&limit=10&offset=0 \n\n' 97 | 98 | 99 | def test_log_response(mocker): 100 | LOG_RESPONSE_HEADER = '--- HTTP Response ---\n' 101 | 102 | ios = io.StringIO() 103 | rl = RequestLogger(file=ios) 104 | 105 | rsp = Response() 106 | rsp.raw = HTTPResponse() 107 | 108 | rsp.status_code = 200 109 | rsp.raw.reason = 'OK' 110 | rl.log_response(rsp) 111 | assert ios.getvalue() == LOG_RESPONSE_HEADER + '200 OK\n\n' 112 | 113 | ios.truncate(0) 114 | ios.seek(0, 0) 115 | 116 | rsp = Response() 117 | rsp.status_code = 200 118 | rsp.reason_phrase = 'OK' 119 | rl.log_response(rsp) 120 | assert ios.getvalue() == LOG_RESPONSE_HEADER + '200 OK\n\n' 121 | 122 | ios.truncate(0) 123 | ios.seek(0, 0) 124 | 125 | json = {'id': 'XX-1234', 'name': 'XXX'} 126 | mocker.patch('requests.models.Response.json', return_value=json) 127 | rsp = Response() 128 | rsp.raw = HTTPResponse() 129 | rsp.headers = { 130 | 'Content-Type': 'application/json', 131 | 'Set-Cookie': ( 132 | 'api_key="test@example.com:abcdefg"; ' 133 | 'expires=Wed, 19 Oct 2022 06:56:08 GMT; HttpOnly;' 134 | ), 135 | } 136 | rsp.status_code = 200 137 | rsp.raw.reason = 'OK' 138 | rl.log_response(rsp) 139 | assert ( 140 | ios.getvalue() 141 | == LOG_RESPONSE_HEADER 142 | + """200 OK 143 | Content-Type: application/json 144 | Set-Cookie: api_key="te******fg"; expires=Wed, 19 Oct 2022 06:56:08 GMT; HttpOnly; 145 | { 146 | "id": "XX-1234", 147 | "name": "XXX" 148 | } 149 | 150 | """ 151 | ) 152 | -------------------------------------------------------------------------------- /tests/client/test_moves.py: -------------------------------------------------------------------------------- 1 | from connect.client import ClientError, ConnectClient, R 2 | 3 | 4 | def test_import_client(): 5 | from cnct import ConnectClient as MovedConnectClient 6 | 7 | assert MovedConnectClient == ConnectClient 8 | 9 | 10 | def test_import_error(): 11 | from cnct import ClientError as MovedClientError 12 | 13 | assert MovedClientError == ClientError 14 | 15 | 16 | def test_import_r(): 17 | from cnct import R as MovedR 18 | 19 | assert MovedR == R 20 | -------------------------------------------------------------------------------- /tests/client/test_openapi.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from connect.client.openapi import OpenAPISpecs 4 | 5 | 6 | def test_load_from_file(): 7 | oa = OpenAPISpecs('tests/data/specs.yml') 8 | assert oa._specs is not None 9 | 10 | 11 | def test_load_from_url(mocked_responses): 12 | mocked_responses.add( 13 | 'GET', 14 | 'https://localhost/specs.yml', 15 | body=open('tests/data/specs.yml', 'r').read(), 16 | ) 17 | 18 | oa = OpenAPISpecs('https://localhost/specs.yml') 19 | assert oa._specs is not None 20 | 21 | 22 | def test_get_title(openapi_specs): 23 | assert openapi_specs.title is not None 24 | 25 | 26 | def test_get_description(openapi_specs): 27 | assert openapi_specs.description is not None 28 | 29 | 30 | def test_get_version(openapi_specs): 31 | assert openapi_specs.version is not None 32 | 33 | 34 | def test_get_tags(openapi_specs): 35 | assert openapi_specs.tags is not None 36 | assert isinstance(openapi_specs.tags, list) 37 | 38 | 39 | @pytest.mark.parametrize( 40 | ('method', 'path', 'expected'), 41 | ( 42 | ('get', 'products', True), 43 | ('get', 'products?eq(status,published)', True), 44 | ('put', 'products', False), 45 | ('get', 'products/PRD-000', True), 46 | ('get', 'products/PRD-000/items', True), 47 | ('post', 'products/PRD-000/endsale', True), 48 | ('get', 'subscriptions/assets', True), 49 | ('get', 'subscriptions/assets/AS-000', True), 50 | ('get', 'resources/assets/AS-000', False), 51 | ), 52 | ) 53 | def test_exists(openapi_specs, method, path, expected): 54 | assert openapi_specs.exists(method, path) is expected 55 | 56 | 57 | def test_get_namespaces(openapi_specs): 58 | namespaces = openapi_specs.get_namespaces() 59 | assert namespaces == [ 60 | 'auth', 61 | 'conversations', 62 | 'dictionary', 63 | 'helpdesk', 64 | 'notifications', 65 | 'offers', 66 | 'pricing', 67 | 'reporting', 68 | 'subscriptions', 69 | 'tier', 70 | 'usage', 71 | ] 72 | 73 | 74 | def test_get_collections(openapi_specs): 75 | cols = openapi_specs.get_collections() 76 | assert cols == [ 77 | 'accounts', 78 | 'agreements', 79 | 'assets', 80 | 'categories', 81 | 'contracts', 82 | 'countries', 83 | 'extensions', 84 | 'forms', 85 | 'hubs', 86 | 'industries', 87 | 'listing-requests', 88 | 'listings', 89 | 'marketplaces', 90 | 'modules', 91 | 'partners', 92 | 'products', 93 | 'requests', 94 | 'users', 95 | ] 96 | 97 | 98 | def test_get_namespaced_collections(openapi_specs): 99 | cols = openapi_specs.get_namespaced_collections('subscriptions') 100 | 101 | assert cols == ['assets', 'requests'] 102 | 103 | 104 | def test_get_collection(openapi_specs): 105 | col = openapi_specs.get_collection('products') 106 | assert col is not None 107 | 108 | col = openapi_specs.get_collection('subscriptions/assets') 109 | assert col is not None 110 | 111 | col = openapi_specs.get_collection('does-not-exists') 112 | assert col is None 113 | 114 | 115 | def test_get_resource(openapi_specs): 116 | res = openapi_specs.get_resource('products/PRD-000') 117 | assert res is not None 118 | 119 | res = openapi_specs.get_resource('subscriptions/assets/AS-0000') 120 | assert res is not None 121 | 122 | res = openapi_specs.get_resource('does-not-exists/dne-id') 123 | assert res is None 124 | 125 | 126 | def test_get_actions(openapi_specs): 127 | actions = openapi_specs.get_actions('products/PRD-000') 128 | assert ['endsale', 'resumesale'] == sorted([x[0] for x in actions]) 129 | 130 | 131 | def test_get_action(openapi_specs): 132 | action = openapi_specs.get_action('products/PRD-000/endsale') 133 | assert action is not None 134 | 135 | action = openapi_specs.get_action('products/PRD-000/does-not-exist') 136 | assert action is None 137 | 138 | 139 | def test_get_nested_collections(openapi_specs): 140 | nested = openapi_specs.get_nested_collections('products/PRD-000') 141 | assert [ 142 | 'actions', 143 | 'agreements', 144 | 'configurations', 145 | 'connections', 146 | 'items', 147 | 'localizations', 148 | 'media', 149 | 'parameters', 150 | 'templates', 151 | 'usage', 152 | 'versions', 153 | ] == [x[0] for x in nested] 154 | 155 | 156 | def test_get_nested_namespaces(openapi_specs): 157 | nested = openapi_specs.get_nested_namespaces('dictionary') 158 | assert nested == ['extensions'] 159 | -------------------------------------------------------------------------------- /tests/client/test_utils.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | from connect.client.utils import ( 4 | ContentRange, 5 | get_headers, 6 | parse_content_range, 7 | resolve_attribute, 8 | ) 9 | from connect.client.version import get_version 10 | 11 | 12 | def test_get_headers(): 13 | headers = get_headers('MY API KEY') 14 | 15 | assert 'Authorization' in headers 16 | assert headers['Authorization'] == 'MY API KEY' 17 | assert 'User-Agent' in headers 18 | 19 | ua = headers['User-Agent'] 20 | 21 | cli, python, system = ua.split() 22 | 23 | assert cli == f'connect-fluent/{get_version()}' 24 | assert python == f'{platform.python_implementation()}/{platform.python_version()}' 25 | assert system == f'{platform.system()}/{platform.release()}' 26 | 27 | 28 | def test_parse_content_range(): 29 | first = 0 30 | last = 99 31 | count = 100 32 | value = f'items {first}-{last}/{count}' 33 | 34 | content_range = parse_content_range(value) 35 | 36 | assert isinstance(content_range, ContentRange) 37 | 38 | assert content_range.first == first 39 | assert content_range.last == last 40 | assert content_range.count == count 41 | 42 | assert parse_content_range(None) is None 43 | 44 | 45 | def test_resolve_attribute(): 46 | data = { 47 | 'first': { 48 | 'second': { 49 | 'third': {'a': 'b'}, 50 | }, 51 | }, 52 | } 53 | 54 | assert resolve_attribute('first.second.third', data) == {'a': 'b'} 55 | 56 | 57 | def test_resolve_attribute_not_found(): 58 | data = { 59 | 'first': { 60 | 'second': { 61 | 'third': {'a': 'b'}, 62 | }, 63 | }, 64 | } 65 | 66 | assert resolve_attribute('a.b.c', data) is None 67 | -------------------------------------------------------------------------------- /tests/client/test_version.py: -------------------------------------------------------------------------------- 1 | from connect.client.version import get_version 2 | 3 | 4 | def test_version_ok(mocker): 5 | mocker.patch('connect.client.version.version', return_value='1.2.3') 6 | assert get_version() == '1.2.3' 7 | 8 | 9 | def test_version_ko(mocker): 10 | mocker.patch('connect.client.version.version', side_effect=Exception) 11 | assert get_version() == '0.0.0' 12 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | import responses 5 | 6 | from connect.client.openapi import OpenAPISpecs 7 | from connect.client.testing.fixtures import ( # noqa 8 | async_client_mocker_factory, 9 | client_mocker_factory, 10 | httpx_mocker, 11 | requests_mocker, 12 | ) 13 | from tests.fixtures.client_models import ( # noqa 14 | action_factory, 15 | async_action_factory, 16 | async_client_mock, 17 | async_col_factory, 18 | async_ns_factory, 19 | async_res_factory, 20 | async_rs_factory, 21 | col_factory, 22 | ns_factory, 23 | res_factory, 24 | rs_factory, 25 | ) 26 | 27 | 28 | @pytest.fixture 29 | def mocked_responses(): 30 | with responses.RequestsMock() as rsps: 31 | yield rsps 32 | 33 | 34 | @pytest.fixture(scope='session') 35 | def openapi_specs(): 36 | return OpenAPISpecs('tests/data/specs.yml') 37 | 38 | 39 | @pytest.fixture 40 | def async_mocker(mocker): 41 | if sys.version_info >= (3, 8): 42 | return mocker 43 | 44 | import mock 45 | 46 | return mock 47 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudblue/connect-python-openapi-client/dbbb0385baadb52e85d8d504b2c29d934ad577eb/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/client_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from connect.client.models import ( 4 | NS, 5 | Action, 6 | AsyncAction, 7 | AsyncCollection, 8 | AsyncNS, 9 | AsyncResource, 10 | AsyncResourceSet, 11 | Collection, 12 | Resource, 13 | ResourceSet, 14 | ) 15 | 16 | 17 | @pytest.fixture 18 | def async_client_mock(async_mocker): 19 | def _async_client_mock(methods=None): 20 | methods = methods or ['execute'] 21 | client = async_mocker.MagicMock() 22 | client.default_limit = 100 23 | for method in methods: 24 | setattr(client, method, async_mocker.AsyncMock()) 25 | return client 26 | 27 | return _async_client_mock 28 | 29 | 30 | @pytest.fixture 31 | def ns_factory(mocker): 32 | client = mocker.MagicMock() 33 | client._endpoint = 'https://example.com/api/v1' 34 | 35 | def _ns_factory( 36 | client=client, 37 | path='namespace', 38 | ): 39 | ns = NS(client, path) 40 | return ns 41 | 42 | return _ns_factory 43 | 44 | 45 | @pytest.fixture 46 | def async_ns_factory(async_client_mock): 47 | client = async_client_mock() 48 | client._endpoint = 'https://example.com/api/v1' 49 | 50 | def _ns_factory( 51 | client=client, 52 | path='namespace', 53 | ): 54 | ns = AsyncNS(client, path) 55 | return ns 56 | 57 | return _ns_factory 58 | 59 | 60 | @pytest.fixture 61 | def col_factory(mocker): 62 | client = mocker.MagicMock() 63 | client._endpoint = 'https://example.com/api/v1' 64 | 65 | def _col_factory( 66 | client=client, 67 | path='namespace', 68 | ): 69 | col = Collection(client, path) 70 | return col 71 | 72 | return _col_factory 73 | 74 | 75 | @pytest.fixture 76 | def async_col_factory(async_client_mock): 77 | client = async_client_mock() 78 | client._endpoint = 'https://example.com/api/v1' 79 | 80 | def _col_factory( 81 | client=client, 82 | path='namespace', 83 | ): 84 | col = AsyncCollection(client, path) 85 | return col 86 | 87 | return _col_factory 88 | 89 | 90 | @pytest.fixture 91 | def res_factory(mocker): 92 | client = mocker.MagicMock() 93 | client._endpoint = 'https://example.com/api/v1' 94 | 95 | def _res_factory( 96 | client=client, 97 | path='{item_id}', 98 | ): 99 | resource = Resource(client, path) 100 | return resource 101 | 102 | return _res_factory 103 | 104 | 105 | @pytest.fixture 106 | def async_res_factory(async_client_mock): 107 | client = async_client_mock() 108 | client._endpoint = 'https://example.com/api/v1' 109 | 110 | def _res_factory( 111 | client=client, 112 | path='{item_id}', 113 | ): 114 | resource = AsyncResource(client, path) 115 | return resource 116 | 117 | return _res_factory 118 | 119 | 120 | @pytest.fixture 121 | def action_factory(mocker): 122 | client = mocker.MagicMock() 123 | client._endpoint = 'https://example.com/api/v1' 124 | 125 | def _action_factory( 126 | client=client, 127 | path='{item_id}', 128 | ): 129 | action = Action(client, path) 130 | return action 131 | 132 | return _action_factory 133 | 134 | 135 | @pytest.fixture 136 | def async_action_factory(async_client_mock): 137 | client = async_client_mock() 138 | client._endpoint = 'https://example.com/api/v1' 139 | 140 | def _action_factory( 141 | client=client, 142 | path='{item_id}', 143 | ): 144 | action = AsyncAction(client, path) 145 | return action 146 | 147 | return _action_factory 148 | 149 | 150 | @pytest.fixture 151 | def rs_factory(mocker): 152 | client = mocker.MagicMock() 153 | client._endpoint = 'https://example.com/api/v1' 154 | client.default_limit = None 155 | 156 | def _rs_factory( 157 | client=client, 158 | path='resources', 159 | query=None, 160 | ): 161 | rs = ResourceSet(client, path, query) 162 | return rs 163 | 164 | return _rs_factory 165 | 166 | 167 | @pytest.fixture 168 | def async_rs_factory(async_client_mock): 169 | client = async_client_mock() 170 | client._endpoint = 'https://example.com/api/v1' 171 | client.default_limit = None 172 | 173 | def _rs_factory( 174 | client=client, 175 | path='resources', 176 | query=None, 177 | ): 178 | rs = AsyncResourceSet(client, path, query) 179 | return rs 180 | 181 | return _rs_factory 182 | -------------------------------------------------------------------------------- /tests/rql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudblue/connect-python-openapi-client/dbbb0385baadb52e85d8d504b2c29d934ad577eb/tests/rql/__init__.py -------------------------------------------------------------------------------- /tests/rql/test_base.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from decimal import Decimal 3 | 4 | import pytest 5 | 6 | from connect.client.rql.base import RQLQuery 7 | 8 | 9 | def test_create(): 10 | q = RQLQuery() 11 | assert q.op == RQLQuery.EXPR 12 | assert q.children == [] 13 | assert q.negated is False 14 | 15 | 16 | def test_create_single_kwarg(): 17 | q = RQLQuery(id='ID') 18 | assert q.op == RQLQuery.EXPR 19 | assert str(q) == 'eq(id,ID)' 20 | assert q.children == [] 21 | assert q.negated is False 22 | 23 | 24 | def test_create_multiple_kwargs(): 25 | q = RQLQuery(id='ID', status__in=('a', 'b'), ok=True) 26 | assert q.op == RQLQuery.AND 27 | assert str(q) == 'and(eq(id,ID),in(status,(a,b)),eq(ok,true))' 28 | assert len(q.children) == 3 29 | assert q.children[0].op == RQLQuery.EXPR 30 | assert q.children[0].children == [] 31 | assert str(q.children[0]) == 'eq(id,ID)' 32 | assert q.children[1].op == RQLQuery.EXPR 33 | assert q.children[1].children == [] 34 | assert str(q.children[1]) == 'in(status,(a,b))' 35 | assert q.children[2].op == RQLQuery.EXPR 36 | assert q.children[2].children == [] 37 | assert str(q.children[2]) == 'eq(ok,true)' 38 | 39 | 40 | def test_len(): 41 | q = RQLQuery() 42 | assert len(q) == 0 43 | 44 | q = RQLQuery(id='ID') 45 | assert len(q) == 1 46 | 47 | q = RQLQuery(id='ID', status__in=('a', 'b')) 48 | assert len(q) == 2 49 | 50 | 51 | def test_bool(): 52 | assert bool(RQLQuery()) is False 53 | assert bool(RQLQuery(id='ID')) is True 54 | assert bool(RQLQuery(id='ID', status__in=('a', 'b'))) is True 55 | 56 | 57 | def test_eq(): 58 | r1 = RQLQuery() 59 | r2 = RQLQuery() 60 | 61 | assert r1 == r2 62 | 63 | r1 = RQLQuery(id='ID') 64 | r2 = RQLQuery(id='ID') 65 | 66 | assert r1 == r2 67 | 68 | r1 = ~RQLQuery(id='ID') 69 | r2 = ~RQLQuery(id='ID') 70 | 71 | assert r1 == r2 72 | 73 | r1 = RQLQuery(id='ID', status__in=('a', 'b')) 74 | r2 = RQLQuery(id='ID', status__in=('a', 'b')) 75 | 76 | assert r1 == r2 77 | 78 | r1 = RQLQuery() 79 | r2 = RQLQuery(id='ID', status__in=('a', 'b')) 80 | 81 | assert r1 != r2 82 | 83 | 84 | def test_or(): 85 | r1 = RQLQuery() 86 | r2 = RQLQuery() 87 | 88 | r3 = r1 | r2 89 | 90 | assert r3 == r1 91 | assert r3 == r2 92 | 93 | r1 = RQLQuery(id='ID') 94 | r2 = RQLQuery(id='ID') 95 | 96 | r3 = r1 | r2 97 | 98 | assert r3 == r1 99 | assert r3 == r2 100 | 101 | r1 = RQLQuery(id='ID') 102 | r2 = RQLQuery(name='name') 103 | 104 | r3 = r1 | r2 105 | 106 | assert r3 != r1 107 | assert r3 != r2 108 | 109 | assert r3.op == RQLQuery.OR 110 | assert r1 in r3.children 111 | assert r2 in r3.children 112 | 113 | r = RQLQuery(id='ID') 114 | assert r | RQLQuery() == r 115 | assert RQLQuery() | r == r 116 | 117 | 118 | def test_or_merge(): 119 | r1 = RQLQuery(id='ID') 120 | r2 = RQLQuery(name='name') 121 | 122 | r3 = RQLQuery(field='value') 123 | r4 = RQLQuery(field__in=('v1', 'v2')) 124 | 125 | or1 = r1 | r2 126 | 127 | or2 = r3 | r4 128 | 129 | or3 = or1 | or2 130 | 131 | assert or3.op == RQLQuery.OR 132 | assert len(or3.children) == 4 133 | assert [r1, r2, r3, r4] == or3.children 134 | 135 | r1 = RQLQuery(id='ID') 136 | r2 = RQLQuery(field='value') 137 | 138 | r3 = r1 | r2 | r2 139 | 140 | assert len(r3) == 2 141 | assert r3.op == RQLQuery.OR 142 | assert [r1, r2] == r3.children 143 | 144 | 145 | def test_and(): 146 | r1 = RQLQuery() 147 | r2 = RQLQuery() 148 | 149 | r3 = r1 & r2 150 | 151 | assert r3 == r1 152 | assert r3 == r2 153 | 154 | r1 = RQLQuery(id='ID') 155 | r2 = RQLQuery(id='ID') 156 | 157 | r3 = r1 & r2 158 | 159 | assert r3 == r1 160 | assert r3 == r2 161 | 162 | r1 = RQLQuery(id='ID') 163 | r2 = RQLQuery(name='name') 164 | 165 | r3 = r1 & r2 166 | 167 | assert r3 != r1 168 | assert r3 != r2 169 | 170 | assert r3.op == RQLQuery.AND 171 | assert r1 in r3.children 172 | assert r2 in r3.children 173 | 174 | r = RQLQuery(id='ID') 175 | assert r & RQLQuery() == r 176 | assert RQLQuery() & r == r 177 | 178 | r1 = RQLQuery(id='ID') 179 | r2 = RQLQuery(field='value') 180 | 181 | r3 = r1 & r2 & r2 182 | 183 | assert len(r3) == 2 184 | assert r3.op == RQLQuery.AND 185 | assert [r1, r2] == r3.children 186 | 187 | 188 | def test_and_or(): 189 | r1 = RQLQuery(id='ID') 190 | r2 = RQLQuery(field='value') 191 | 192 | r3 = RQLQuery(other='value2') 193 | r4 = RQLQuery(inop__in=('a', 'b')) 194 | 195 | r5 = r1 & r2 & (r3 | r4) 196 | 197 | assert r5.op == RQLQuery.AND 198 | assert str(r5) == 'and(eq(id,ID),eq(field,value),or(eq(other,value2),in(inop,(a,b))))' 199 | 200 | r5 = r1 & r2 | r3 201 | 202 | assert str(r5) == 'or(and(eq(id,ID),eq(field,value)),eq(other,value2))' 203 | 204 | r5 = r1 & (r2 | r3) 205 | 206 | assert str(r5) == 'and(eq(id,ID),or(eq(field,value),eq(other,value2)))' 207 | 208 | r5 = (r1 & r2) | (r3 & r4) 209 | 210 | assert str(r5) == 'or(and(eq(id,ID),eq(field,value)),and(eq(other,value2),in(inop,(a,b))))' 211 | 212 | r5 = (r1 & r2) | ~r3 213 | 214 | assert str(r5) == 'or(and(eq(id,ID),eq(field,value)),not(eq(other,value2)))' 215 | 216 | 217 | def test_and_merge(): 218 | r1 = RQLQuery(id='ID') 219 | r2 = RQLQuery(name='name') 220 | 221 | r3 = RQLQuery(field='value') 222 | r4 = RQLQuery(field__in=('v1', 'v2')) 223 | 224 | and1 = r1 & r2 225 | 226 | and2 = r3 & r4 227 | 228 | and3 = and1 & and2 229 | 230 | assert and3.op == RQLQuery.AND 231 | assert len(and3.children) == 4 232 | assert [r1, r2, r3, r4] == and3.children 233 | 234 | 235 | @pytest.mark.parametrize('op', ('eq', 'ne', 'gt', 'ge', 'le', 'lt')) 236 | def test_dotted_path_comp(op): 237 | R = RQLQuery 238 | assert str(getattr(R().asset.id, op)('value')) == f'{op}(asset.id,value)' 239 | assert str(getattr(R().asset.id, op)(True)) == f'{op}(asset.id,true)' 240 | assert str(getattr(R().asset.id, op)(False)) == f'{op}(asset.id,false)' 241 | assert str(getattr(R().asset.id, op)(10)) == f'{op}(asset.id,10)' 242 | assert str(getattr(R().asset.id, op)(10.678937)) == f'{op}(asset.id,10.678937)' 243 | 244 | d = Decimal('32983.328238273') 245 | assert str(getattr(R().asset.id, op)(d)) == f'{op}(asset.id,{str(d)})' 246 | 247 | d = date.today() 248 | assert str(getattr(R().asset.id, op)(d)) == f'{op}(asset.id,{d.isoformat()})' 249 | 250 | d = datetime.now() 251 | assert str(getattr(R().asset.id, op)(d)) == f'{op}(asset.id,{d.isoformat()})' 252 | 253 | class Test: 254 | pass 255 | 256 | test = Test() 257 | 258 | with pytest.raises(TypeError): 259 | getattr(R().asset.id, op)(test) 260 | 261 | 262 | @pytest.mark.parametrize('op', ('like', 'ilike')) 263 | def test_dotted_path_search(op): 264 | R = RQLQuery 265 | assert str(getattr(R().asset.id, op)('value')) == f'{op}(asset.id,value)' 266 | assert str(getattr(R().asset.id, op)('*value')) == f'{op}(asset.id,*value)' 267 | assert str(getattr(R().asset.id, op)('value*')) == f'{op}(asset.id,value*)' 268 | assert str(getattr(R().asset.id, op)('*value*')) == f'{op}(asset.id,*value*)' 269 | 270 | 271 | @pytest.mark.parametrize( 272 | ('method', 'op'), 273 | ( 274 | ('in_', 'in'), 275 | ('oneof', 'in'), 276 | ('out', 'out'), 277 | ), 278 | ) 279 | def test_dotted_path_list(method, op): 280 | R = RQLQuery 281 | 282 | rexpr = getattr(R().asset.id, method)(('first', 'second')) 283 | assert str(rexpr) == f'{op}(asset.id,(first,second))' 284 | 285 | rexpr = getattr(R().asset.id, method)(['first', 'second']) 286 | assert str(rexpr) == f'{op}(asset.id,(first,second))' 287 | 288 | with pytest.raises(TypeError): 289 | getattr(R().asset.id, method)('Test') 290 | 291 | 292 | @pytest.mark.parametrize( 293 | ('expr', 'value', 'expected_op'), 294 | ( 295 | ('null', True, 'eq'), 296 | ('null', False, 'ne'), 297 | ('empty', True, 'eq'), 298 | ('empty', False, 'ne'), 299 | ), 300 | ) 301 | def test_dotted_path_bool(expr, value, expected_op): 302 | R = RQLQuery 303 | 304 | assert str(getattr(R().asset.id, expr)(value)) == f'{expected_op}(asset.id,{expr}())' 305 | 306 | 307 | def test_dotted_path_already_evaluated(): 308 | q = RQLQuery().first.second.eq('value') 309 | 310 | with pytest.raises(AttributeError): 311 | q.third 312 | 313 | 314 | def test_str(): 315 | assert str(RQLQuery(id='ID')) == 'eq(id,ID)' 316 | assert str(~RQLQuery(id='ID')) == 'not(eq(id,ID))' 317 | assert str(~RQLQuery(id='ID', field='value')) == 'not(and(eq(id,ID),eq(field,value)))' 318 | assert str(RQLQuery()) == '' 319 | 320 | 321 | def test_hash(): 322 | s = set() 323 | 324 | r = RQLQuery(id='ID', field='value') 325 | 326 | s.add(r) 327 | s.add(r) 328 | 329 | assert len(s) == 1 330 | -------------------------------------------------------------------------------- /tests/rql/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from connect.client.rql.utils import parse_kwargs 4 | 5 | 6 | def test_simple(): 7 | expressions = parse_kwargs({'field': 'value'}) 8 | assert isinstance(expressions, list) 9 | assert len(expressions) == 1 10 | assert expressions[0] == 'eq(field,value)' 11 | 12 | 13 | def test_comparison(): 14 | for op in ('eq', 'ne', 'lt', 'le', 'gt', 'ge'): 15 | expressions = parse_kwargs({f'field__{op}': 'value'}) 16 | assert isinstance(expressions, list) 17 | assert len(expressions) == 1 18 | assert expressions[0] == f'{op}(field,value)' 19 | 20 | 21 | def test_search(): 22 | for op in ('like', 'ilike'): 23 | expressions = parse_kwargs({f'field__{op}': 'value'}) 24 | assert isinstance(expressions, list) 25 | assert len(expressions) == 1 26 | assert expressions[0] == f'{op}(field,value)' 27 | 28 | 29 | def test_list(): 30 | for op in ('out', 'in'): 31 | expressions = parse_kwargs({f'field__{op}': ('value1', 'value2')}) 32 | assert isinstance(expressions, list) 33 | assert len(expressions) == 1 34 | assert expressions[0] == f'{op}(field,(value1,value2))' 35 | 36 | 37 | def test_nested_fields(): 38 | expressions = parse_kwargs({'field__nested': 'value'}) 39 | assert isinstance(expressions, list) 40 | assert len(expressions) == 1 41 | assert expressions[0] == 'eq(field.nested,value)' 42 | 43 | 44 | @pytest.mark.parametrize( 45 | ('expr', 'value', 'expected_op'), 46 | ( 47 | ('null', True, 'eq'), 48 | ('null', False, 'ne'), 49 | ('empty', True, 'eq'), 50 | ('empty', False, 'ne'), 51 | ), 52 | ) 53 | def test_null(expr, value, expected_op): 54 | expressions = parse_kwargs({f'field__{expr}': value}) 55 | assert isinstance(expressions, list) 56 | assert len(expressions) == 1 57 | assert expressions[0] == f'{expected_op}(field,{expr}())' 58 | --------------------------------------------------------------------------------