├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── test.yml │ └── test_and_deploy.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── VERSION ├── apiclient ├── __init__.py ├── authentication_methods.py ├── client.py ├── decorates.py ├── error_handlers.py ├── exceptions.py ├── paginators.py ├── request_formatters.py ├── request_strategies.py ├── response.py ├── response_handlers.py ├── retrying.py └── utils │ ├── __init__.py │ ├── typing.py │ └── warnings.py ├── pyproject.toml ├── readme-data └── jetbrains.svg ├── scripts ├── dep_checker.py ├── generate_changelog.py ├── update_version.py └── upload_new_package.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── helpers.py ├── integration_tests │ ├── __init__.py │ ├── client.py │ └── test_client_integration.py ├── test_authentication_methods.py ├── test_client.py ├── test_endpoints.py ├── test_error_handlers.py ├── test_paginators.py ├── test_request_formatters.py ├── test_request_strategies.py ├── test_response.py ├── test_response_handlers.py ├── test_retrying.py ├── test_warnings.py └── vcr_cassettes │ ├── cassette.yaml │ └── error_cassette.yaml ├── tox.ini └── upload_new_package.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Versions (please complete the following information):** 27 | - Python version: [e.g. 3.8.2] 28 | - API Client version: [e.g. 1.2.1] 29 | - Dependency versions: [e.g. requests==2.1.6, tenacity==5.1.0, etc.] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | depcheck: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python Version 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.8 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install tox 22 | pip install -e . 23 | - name: Dependency Checks 24 | run: | 25 | python ./scripts/dep_checker.py 26 | lint: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v2 30 | - name: Set up Python Version 31 | uses: actions/setup-python@v2 32 | with: 33 | python-version: 3.8 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install tox 38 | - name: Lint 39 | run: | 40 | tox -e lint 41 | test: 42 | needs: [depcheck, lint] 43 | runs-on: ubuntu-latest 44 | strategy: 45 | matrix: 46 | python-version: [ 3.6, 3.7, 3.8 ] 47 | 48 | steps: 49 | - uses: actions/checkout@v2 50 | - name: Set up Python ${{ matrix.python-version }} 51 | uses: actions/setup-python@v2 52 | with: 53 | python-version: ${{ matrix.python-version }} 54 | - name: Install dependencies 55 | run: | 56 | python -m pip install --upgrade pip 57 | pip install tox 58 | - name: Unit Tests 59 | run: | 60 | tox 61 | -------------------------------------------------------------------------------- /.github/workflows/test_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Python test and deploy 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | 8 | jobs: 9 | 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [3.7, 3.8] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install tox 26 | - name: Lint Checks 27 | run: | 28 | tox -e lint 29 | - name: Unit Tests 30 | run: | 31 | tox 32 | 33 | deploy: 34 | needs: test 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | - name: Set Up Build 39 | uses: actions/setup-python@v2 40 | with: 41 | python-version: 3.8 42 | - name: Install dependencies 43 | run: | 44 | python -m pip install --upgrade pip 45 | pip install -e .[deploy] 46 | - name: Update RC Version 47 | run: | 48 | python scripts/update_version.py ${GITHUB_REF} 49 | - name: Build Dist 50 | run: | 51 | rm -rf dist/* 52 | python setup.py sdist 53 | - name: Publish 54 | env: 55 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 56 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 57 | run: | 58 | python scripts/upload_new_package.py 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | htmlcov/ 3 | *.egg-info 4 | *.pyc 5 | *__pycache__ 6 | examples/ 7 | build/ 8 | dist/ 9 | .python-version 10 | .tox/ 11 | pip-wheel-metadata/ 12 | .idea/ 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 The Python Packaging Authority 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include apiclient * 2 | include VERSION 3 | 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Unit Tests](https://github.com/MikeWooster/api-client/actions/workflows/test.yml/badge.svg)](https://github.com/MikeWooster/api-client/actions/workflows/test.yml) 2 | 3 | # Note to users of this API 4 | 5 | To those that have been paying attention to the commit history will note that 6 | I have had no time to actively maintain this library. If there are any volunteers 7 | to continue the development of this library, I would be happy to add you as 8 | contributors. Please get in touch, and I will try to sort out a group to continue 9 | this development. Also note, that my reply time may not be that timely, so please 10 | be patient. 11 | 12 | # Python API Client 13 | 14 | A client for communicating with an api should be a clean abstraction 15 | over the third part api you are communicating with. It should be easy to 16 | understand and have the sole responsibility of calling the endpoints and 17 | returning data. 18 | 19 | To achieve this, `APIClient` takes care of the other (often duplicated) 20 | responsibilities, such as authentication and response handling, moving 21 | that code away from the clean abstraction you have designed. 22 | 23 | ## Quick links 24 | 1. [Installation](#Installation) 25 | 2. [Client in action](#Usage) 26 | 3. [Adding retries to requests](#Retrying) 27 | 4. [Working with paginated responses](#Pagination) 28 | 5. [Authenticating your requests](#Authentication-Methods) 29 | 6. [Handling the formats of your responses](#Response-Handlers) 30 | 7. [Correctly encoding your outbound request data](#Request-Formatters) 31 | 8. [Handling bad requests and responses](#Exceptions) 32 | 9. [Endpoints as code](#Endpoints) 33 | 10. [Extensions](#Extensions) 34 | 11. [Roadmap](#Roadmap) 35 | 36 | ## Installation 37 | 38 | ```bash 39 | pip install api-client 40 | ``` 41 | 42 | ## Usage 43 | 44 | ### Simple Example 45 | ```python 46 | from apiclient import APIClient 47 | 48 | class MyClient(APIClient): 49 | 50 | def list_customers(self): 51 | url = "http://example.com/customers" 52 | return self.get(url) 53 | 54 | def add_customer(self, customer_info): 55 | url = "http://example.com/customers" 56 | return self.post(url, data=customer_info) 57 | 58 | >>> client = MyClient() 59 | >>> client.add_customer({"name": "John Smith", "age": 28}) 60 | >>> client.list_customers() 61 | [ 62 | ..., 63 | {"name": "John Smith", "age": 28}, 64 | ] 65 | ``` 66 | The `APIClient` exposes a number of predefined methods that you can call 67 | This example uses `get` to perform a GET request on an endpoint. 68 | Other methods include: `post`, `put`, `patch` and `delete`. More 69 | information on these methods is documented in the [Interface](#APIClient-Interface). 70 | 71 | 72 | For a more complex use case example, see: [Extended example](#Extended-Example) 73 | 74 | ## Retrying 75 | 76 | To add some robustness to your client, the power of [tenacity](https://github.com/jd/tenacity) 77 | has been harnessed to add a `@retry_request` decorator to the `apiclient` toolkit. 78 | 79 | This will retry any request which responds with a 5xx status_code (which is normally safe 80 | to do as this indicates something went wrong when trying to make the request), or when an 81 | `UnexpectedError` occurs when attempting to establish the connection. 82 | 83 | `@retry_request` has been configured to retry for a maximum of 5 minutes, with an exponential 84 | backoff strategy. For more complicated uses, the user can use tenacity themselves to create 85 | their own custom decorator. 86 | 87 | Usage: 88 | 89 | ```python 90 | from apiclient import retry_request 91 | 92 | class MyClient(APIClient): 93 | 94 | @retry_request 95 | def retry_enabled_method(): 96 | ... 97 | 98 | ``` 99 | 100 | For more complex use cases, you can build your own retry decorator using 101 | tenacity along with the custom retry strategy. 102 | 103 | For example, you can build a retry decorator that retries `APIRequestError` 104 | which waits for 2 seconds between retries and gives up after 5 attempts. 105 | 106 | ```python 107 | import tenacity 108 | from apiclient.retrying import retry_if_api_request_error 109 | 110 | retry_decorator = tenacity.retry( 111 | retry=retry_if_api_request_error(), 112 | wait=tenacity.wait_fixed(2), 113 | stop=tenacity.stop_after_attempt(5), 114 | reraise=True, 115 | ) 116 | ``` 117 | 118 | Or you can build a decorator that will retry only on specific status 119 | codes (following a failure). 120 | 121 | ```python 122 | retry_decorator = tenacity.retry( 123 | retry=retry_if_api_request_error(status_codes=[500, 501, 503]), 124 | wait=tenacity.wait_fixed(2), 125 | stop=tenacity.stop_after_attempt(5), 126 | reraise=True, 127 | ) 128 | ``` 129 | 130 | 131 | ## Pagination 132 | 133 | In order to support contacting pages that respond with multiple pages of data when making get requests, 134 | add a `@paginated` decorator to your client method. `@paginated` can paginate the requests either where 135 | the pages are specified in the query parameters, or by modifying the url. 136 | 137 | Usage is simple in both cases; paginator decorators take a Callable with two required arguments: 138 | - `by_query_params` -> callable takes `response` and `previous_page_params`. 139 | - `by_url` -> callable takes `respones` and `previous_page_url`. 140 | 141 | The callable will need to return either the params in the case of `by_query_params`, or a new url in the 142 | case of `by_url`. 143 | If the response is the last page, the function should return None. 144 | 145 | Usage: 146 | 147 | ```python 148 | from apiclient.paginators import paginated 149 | 150 | 151 | def next_page_by_params(response, previous_page_params): 152 | # Function reads the response data and returns the query param 153 | # that tells the next request to go to. 154 | return {"next": response["pages"]["next"]} 155 | 156 | 157 | def next_page_by_url(response, previous_page_url): 158 | # Function reads the response and returns the url as string 159 | # where the next page of data lives. 160 | return response["pages"]["next"]["url"] 161 | 162 | 163 | class MyClient(APIClient): 164 | 165 | @paginated(by_query_params=next_page_by_params) 166 | def paginated_example_one(): 167 | ... 168 | 169 | @paginated(by_url=next_page_by_url) 170 | def paginated_example_two(): 171 | ... 172 | 173 | ``` 174 | 175 | 176 | ## Authentication Methods 177 | Authentication methods provide a way in which you can customize the 178 | client with various authentication schemes through dependency injection, 179 | meaning you can change the behaviour of the client without changing the 180 | underlying implementation. 181 | 182 | The apiclient supports the following authentication methods, by specifying 183 | the initialized class on initialization of the client, as follows: 184 | ```python 185 | client = ClientImplementation( 186 | authentication_method=(), 187 | response_handler=..., 188 | request_formatter=..., 189 | ) 190 | ``` 191 | 192 | ### `NoAuthentication` 193 | This authentication method simply does not add anything to the client, 194 | allowing the api to contact APIs that do not enforce any authentication. 195 | 196 | Example: 197 | ```python 198 | client = ClientImplementation( 199 | authentication_method=NoAuthentication(), 200 | response_handler=..., 201 | request_formatter=..., 202 | ) 203 | ``` 204 | 205 | ### `QueryParameterAuthentication` 206 | This authentication method adds the relevant parameter and token to the 207 | client query parameters. Usage is as follows: 208 | 209 | ```python 210 | client = ClientImplementation( 211 | authentication_method=QueryParameterAuthentication(parameter="apikey", token="secret_token"), 212 | response_handler=..., 213 | request_formatter=..., 214 | ) 215 | ``` 216 | Example. Contacting a url with the following data 217 | ``` 218 | http://api.example.com/users?age=27 219 | ``` 220 | Will add the authentication parameters to the outgoing request: 221 | ``` 222 | http://api.example.com/users?age=27&apikey=secret_token 223 | ``` 224 | 225 | ### `HeaderAuthentication` 226 | This authentication method adds the relevant authorization header to 227 | the outgoing request. Usage is as follows: 228 | ```python 229 | client = ClientImplementation( 230 | authentication_method=HeaderAuthentication(token="secret_value"), 231 | response_handler=..., 232 | request_formatter=..., 233 | ) 234 | 235 | # Constructs request header: 236 | {"Authorization": "Bearer secret_value"} 237 | ``` 238 | The `Authorization` parameter and `Bearer` scheme can be adjusted by 239 | specifying on method initialization. 240 | ```python 241 | authentication_method=HeaderAuthentication( 242 | token="secret_value" 243 | parameter="apikey", 244 | scheme="Token", 245 | ) 246 | 247 | # Constructs request header: 248 | {"apikey": "Token secret_value"} 249 | ``` 250 | 251 | Or alternatively, when APIs do not require a scheme to be set, you can 252 | specify it as a value that evaluates to False to remove the scheme from 253 | the header: 254 | ```python 255 | authentication_method=HeaderAuthentication( 256 | token="secret_value" 257 | parameter="token", 258 | scheme=None, 259 | ) 260 | 261 | # Constructs request header: 262 | {"token": "secret_value"} 263 | ``` 264 | 265 | Additional header values can be passed in as a dict here when API's require more than one 266 | header to authenticate: 267 | ```python 268 | authentication_method=HeaderAuthentication( 269 | token="secret_value" 270 | parameter="token", 271 | scheme=None, 272 | extra={"more": "another_secret"} 273 | ) 274 | 275 | # Constructs request header: 276 | {"token": "secret_value", "more": "another_secret"} 277 | ``` 278 | 279 | ### `BasicAuthentication` 280 | This authentication method enables specifying a username and password to APIs 281 | that require such. 282 | ```python 283 | client = ClientImplementation( 284 | authentication_method=BasicAuthentication(username="foo", password="secret_value"), 285 | response_handler=..., 286 | request_formatter=..., 287 | ) 288 | ``` 289 | 290 | ### `CookieAuthentication` 291 | This authentication method allows a user to specify a url which is used 292 | to authenticate an initial request, made at APIClient initialization, 293 | with the authorization tokens then persisted for the duration of the 294 | client instance in cookie storage. 295 | 296 | These cookies use the `http.cookiejar.CookieJar()` and are set on the 297 | session so that all future requests contain these cookies. 298 | 299 | As the method of authentication at the endpoint is not standardised 300 | across API's, the authentication method can be customized using one of 301 | the already defined authentication methods; `QueryParameterAuthentication`, 302 | `HeaderAuthentication`, `BasicAuthentication`. 303 | 304 | ```python 305 | client = ClientImplementation( 306 | authentication_method=( 307 | CookieAuthentication( 308 | auth_url="https://example.com/authenticate", 309 | authentication=HeaderAuthentication("1234-secret-key"), 310 | ), 311 | response_handler=..., 312 | request_formatter=..., 313 | ) 314 | ``` 315 | 316 | ## Response Handlers 317 | 318 | Response handlers provide a standard way of handling the final response 319 | following a successful request to the API. These must inherit from 320 | `BaseResponseHandler` and implement the `get_request_data()` method which 321 | will take the `requests.Response` object and parse the data accordingly. 322 | 323 | The apiclient supports the following response handlers, by specifying 324 | the class on initialization of the client as follows: 325 | 326 | The response handler can be omitted, in which case no formatting is applied to the 327 | outgoing data. 328 | 329 | ```python 330 | client = ClientImplementation( 331 | authentication_method=..., 332 | response_handler=, 333 | request_formatter=..., 334 | ) 335 | ``` 336 | 337 | ### `RequestsResponseHandler` 338 | Handler that simply returns the original `Response` object with no 339 | alteration. 340 | 341 | Example: 342 | ```python 343 | client = ClientImplementation( 344 | authentication_method=..., 345 | response_handler=RequestsResponseHandler, 346 | request_formatter=..., 347 | ) 348 | ``` 349 | 350 | ### `JsonResponseHandler` 351 | Handler that parses the response data to `json` and returns the dictionary. 352 | If an error occurs trying to parse to json then a `ResponseParseError` 353 | will be raised. 354 | 355 | Example: 356 | ```python 357 | client = ClientImplementation( 358 | authentication_method=..., 359 | response_handler=JsonResponseHandler, 360 | request_formatter=..., 361 | ) 362 | ``` 363 | 364 | ### `XmlResponseHandler` 365 | Handler that parses the response data to an `xml.etree.ElementTree.Element`. 366 | If an error occurs trying to parse to xml then a `ResponseParseError` 367 | will be raised. 368 | 369 | Example: 370 | ```python 371 | client = ClientImplementation( 372 | authentication_method=..., 373 | response_handler=XmlResponseHandler, 374 | request_formatter=..., 375 | ) 376 | ``` 377 | 378 | ## Request Formatters 379 | 380 | Request formatters provide a way in which the outgoing request data can 381 | be encoded before being sent, and to set the headers appropriately. 382 | 383 | These must inherit from `BaseRequestFormatter` and implement the `format()` 384 | method which will take the outgoing `data` object and format accordingly 385 | before making the request. 386 | 387 | The apiclient supports the following request formatters, by specifying 388 | the class on initialization of the client as follows: 389 | 390 | ```python 391 | client = ClientImplementation( 392 | authentication_method=..., 393 | response_handler=..., 394 | request_formatter=, 395 | ) 396 | ``` 397 | 398 | ### `JsonRequestFormatter` 399 | 400 | Formatter that converts the data into a json format and adds the 401 | `application/json` Content-type header to the outgoing requests. 402 | 403 | 404 | Example: 405 | ```python 406 | client = ClientImplementation( 407 | authentication_method=..., 408 | response_handler=..., 409 | request_formatter=JsonRequestFormatter, 410 | ) 411 | ``` 412 | 413 | ## Exceptions 414 | 415 | The exception handling for `api-client` has been designed in a way so that all exceptions inherit from 416 | one base exception type: `APIClientError`. From there, the exceptions have been broken down into the 417 | following categories: 418 | 419 | ### `ResponseParseError` 420 | 421 | Something went wrong when trying to parse the successful response into the defined format. This could be due 422 | to a misuse of the ResponseHandler, i.e. configuring the client with an `XmlResponseHandler` instead of 423 | a `JsonResponseHandler` 424 | 425 | ### `APIRequestError` 426 | 427 | Something went wrong when making the request. These are broken down further into the following categories to provide 428 | greater granularity and control. 429 | 430 | #### `RedirectionError` 431 | A redirection status code (3xx) was returned as a final code when making the 432 | request. This means that no data can be returned to the client as we could 433 | not find the requested resource as it had moved. 434 | 435 | 436 | ### `ClientError` 437 | A clienterror status code (4xx) was returned when contacting the API. The most common cause of 438 | these errors is misuse of the client, i.e. sending bad data to the API. 439 | 440 | 441 | ### `ServerError` 442 | The API was unreachable when making the request. I.e. a 5xx status code. 443 | 444 | 445 | ### `UnexpectedError` 446 | An unexpected error occurred when using the client. This will typically happen when attempting 447 | to make the request, for example, the client never receives a response. It can also occur to 448 | unexpected status codes (>= 600). 449 | 450 | ## Custom Error Handling 451 | 452 | Error handlers allow you to customize the way request errors are handled in the application. 453 | 454 | Create a new error handler, extending `BaseErrorHandler` and implement the `get_exception` 455 | static method. 456 | 457 | Pass the custom error handler into your client upon initialization. 458 | 459 | Example: 460 | ```python 461 | from apiclient.error_handlers import BaseErrorHandler 462 | from apiclient import exceptions 463 | from apiclient.response import Response 464 | 465 | class MyErrorHandler(BaseErrorHandler): 466 | 467 | @staticmethod 468 | def get_exception(response: Response) -> exceptions.APIRequestError: 469 | """Parses client errors to extract bad request reasons.""" 470 | if 400 <= response.get_status_code() < 500: 471 | json = response.get_json() 472 | return exceptions.ClientError(json["error"]["reason"]) 473 | 474 | return exceptions.APIRequestError("something went wrong") 475 | 476 | ``` 477 | In the above example, you will notice that we are utilising an internal 478 | `Response` object. This has been designed to abstract away the underlying response 479 | returned from whatever strategy that you are using. The `Response` contains the following 480 | methods: 481 | 482 | * `get_original`: returns the underlying response object. This has been implemented 483 | for convenience and shouldn't be relied on. 484 | * `get_status_code`: returns the integer status code. 485 | * `get_raw_data`: returns the textual data from the response. 486 | * `get_json`: should return the json from the response. 487 | * `get_status_reason`: returns the reason for any HTTP error code. 488 | * `get_requested_url`: returns the url that the client was requesting. 489 | 490 | ## Request Strategy 491 | 492 | The design of the client provides a stub of a client, exposing the required methods; `get`, 493 | `post`, etc. And this then calls the implemented methods of a request strategy. 494 | 495 | This allows us to swap in/out strategies when needed. I.e. you can write your own 496 | strategy that implements a different library (e.g. `urllib`). Or you could pass in a 497 | mock strategy for testing purposes. 498 | 499 | Example strategy for testing: 500 | ```python 501 | from unittest.mock import Mock 502 | 503 | from apiclient import APIClient 504 | from apiclient.request_strategies import BaseRequestStrategy 505 | 506 | def test_get_method(): 507 | """test that the get method is called on the underlying strategy. 508 | 509 | This does not execute any external HTTP call. 510 | """ 511 | mock_strategy = Mock(spec=BaseRequestStrategy) 512 | client = APIClient(request_strategy=mock_strategy) 513 | client.get("http://google.com") 514 | mock_strategy.get.assert_called_with("http://google.com", params=None) 515 | ``` 516 | 517 | ## Endpoints 518 | 519 | The apiclient also provides a convenient way of defining url endpoints with 520 | use of the `@endpoint` decorator. In order to decorate a class with `@endpoint` 521 | the decorated class must define a `base_url` attribute along with the required 522 | resources. The decorator will combine the base_url with the resource. 523 | 524 | Example: 525 | 526 | ```python 527 | from apiclient import endpoint 528 | 529 | @endpoint(base_url="http://foo.com") 530 | class Endpoint: 531 | resource = "search" 532 | 533 | >>> Endpoint.resource 534 | "http://foo.com/search" 535 | ``` 536 | 537 | ## Extensions 538 | 539 | ### Marshalling JSON 540 | 541 | [api-client-jsonmarshal](https://github.com/MikeWooster/api-client-jsonmarshal): automatically 542 | marshal to/from JSON into plain python dataclasses. Full usage examples can be found in the extensions home page. 543 | 544 | ### Pydantic 545 | 546 | [api-client-pydantic](https://github.com/mom1/api-client-pydantic): validate request data and converting json straight 547 | to pydantic class. 548 | 549 | ## Extended Example 550 | 551 | ```python 552 | from apiclient import ( 553 | APIClient, 554 | endpoint, 555 | paginated, 556 | retry_request, 557 | HeaderAuthentication, 558 | JsonResponseHandler, 559 | JsonRequestFormatter, 560 | ) 561 | from apiclient.exceptions import APIClientError 562 | 563 | # Define endpoints, using the provided decorator. 564 | @endpoint(base_url="https://jsonplaceholder.typicode.com") 565 | class Endpoint: 566 | todos = "todos" 567 | todo = "todos/{id}" 568 | 569 | 570 | def get_next_page(response): 571 | return { 572 | "limit": response["limit"], 573 | "offset": response["offset"] + response["limit"], 574 | } 575 | 576 | 577 | # Extend the client for your API integration. 578 | class JSONPlaceholderClient(APIClient): 579 | 580 | @paginated(by_query_params=get_next_page) 581 | def get_all_todos(self) -> dict: 582 | return self.get(Endpoint.todos) 583 | 584 | @retry_request 585 | def get_todo(self, todo_id: int) -> dict: 586 | url = Endpoint.todo.format(id=todo_id) 587 | return self.get(url) 588 | 589 | 590 | # Initialize the client with the correct authentication method, 591 | # response handler and request formatter. 592 | >>> client = JSONPlaceholderClient( 593 | authentication_method=HeaderAuthentication(token=""), 594 | response_handler=JsonResponseHandler, 595 | request_formatter=JsonRequestFormatter, 596 | ) 597 | 598 | 599 | # Call the client methods. 600 | >>> client.get_all_todos() 601 | [ 602 | { 603 | 'userId': 1, 604 | 'id': 1, 605 | 'title': 'delectus aut autem', 606 | 'completed': False 607 | }, 608 | ..., 609 | { 610 | 'userId': 10, 611 | 'id': 200, 612 | 'title': 'ipsam aperiam voluptates qui', 613 | 'completed': False 614 | } 615 | ] 616 | 617 | 618 | >>> client.get_todo(45) 619 | { 620 | 'userId': 3, 621 | 'id': 45, 622 | 'title': 'velit soluta adipisci molestias reiciendis harum', 623 | 'completed': False 624 | } 625 | 626 | 627 | # REST APIs correctly adhering to the status codes to provide meaningful 628 | # responses will raise the appropriate exeptions. 629 | >>> client.get_todo(450) 630 | # NotFound: 404 Error: Not Found for url: https://jsonplaceholder.typicode.com/todos/450 631 | 632 | >>> try: 633 | ... client.get_todo(450) 634 | ... except APIClientError: 635 | ... print("All client exceptions inherit from APIClientError") 636 | "All client exceptions inherit from APIClientError" 637 | 638 | ``` 639 | 640 | ## APIClient Interface 641 | The `APIClient` provides the following public interface: 642 | * `post(self, endpoint: str, data: dict, params: OptionalDict = None)` 643 | 644 | Delegate to POST method to send data and return response from endpoint. 645 | 646 | * `get(endpoint: str, params: OptionalDict = None)` 647 | 648 | Delegate to GET method to get response from endpoint. 649 | 650 | * `put(endpoint: str, data: dict, params: OptionalDict = None)` 651 | 652 | Delegate to PUT method to send and overwrite data and return response from endpoint. 653 | 654 | * `patch(endpoint: str, data: dict, params: OptionalDict = None)` 655 | 656 | Delegate to PATCH method to send and update data and return response from endpoint 657 | 658 | * `delete(endpoint: str, params: OptionalDict = None)` 659 | 660 | Delegate to DELETE method to remove resource located at endpoint. 661 | 662 | * `get_request_timeout() -> float` 663 | 664 | By default, all requests have been set to have a default timeout of 10.0 s. This 665 | is to avoid the request waiting forever for a response, and is recommended 666 | to always be set to a value in production applications. It is however possible to 667 | override this method to return the timeout required by your application. 668 | 669 | ## Mentions 670 | 671 | Many thanks to [JetBrains](https://www.jetbrains.com/?from=api-client) for supplying me with a license to use their product in the development 672 | of this tool. 673 | 674 | ![JetBrains](readme-data/jetbrains.svg) 675 | 676 | ## Roadmap 677 | 678 | 1. Enable async support for APIClient. 679 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.3.1 2 | -------------------------------------------------------------------------------- /apiclient/__init__.py: -------------------------------------------------------------------------------- 1 | # Allow direct access to the base client and other methods. 2 | from apiclient.authentication_methods import ( 3 | BasicAuthentication, 4 | HeaderAuthentication, 5 | NoAuthentication, 6 | QueryParameterAuthentication, 7 | ) 8 | from apiclient.client import APIClient 9 | from apiclient.decorates import endpoint 10 | from apiclient.paginators import paginated 11 | from apiclient.request_formatters import JsonRequestFormatter 12 | from apiclient.response_handlers import JsonResponseHandler, RequestsResponseHandler, XmlResponseHandler 13 | from apiclient.retrying import retry_request 14 | -------------------------------------------------------------------------------- /apiclient/authentication_methods.py: -------------------------------------------------------------------------------- 1 | import http.cookiejar 2 | from typing import TYPE_CHECKING, Dict, Optional, Union 3 | 4 | from apiclient.utils.typing import BasicAuthType, OptionalStr 5 | 6 | if TYPE_CHECKING: # pragma: no cover 7 | # Stupid way of getting around cyclic imports when 8 | # using typehinting. 9 | from apiclient import APIClient 10 | 11 | 12 | class BaseAuthenticationMethod: 13 | def get_headers(self) -> dict: 14 | return {} 15 | 16 | def get_query_params(self) -> dict: 17 | return {} 18 | 19 | def get_username_password_authentication(self) -> Optional[BasicAuthType]: 20 | return None 21 | 22 | def perform_initial_auth(self, client: "APIClient"): 23 | pass 24 | 25 | 26 | class NoAuthentication(BaseAuthenticationMethod): 27 | """No authentication methods needed for API.""" 28 | 29 | pass 30 | 31 | 32 | class QueryParameterAuthentication(BaseAuthenticationMethod): 33 | """Authentication provided as part of the query parameter.""" 34 | 35 | def __init__(self, parameter: str, token: str): 36 | self._parameter = parameter 37 | self._token = token 38 | 39 | def get_query_params(self): 40 | return {self._parameter: self._token} 41 | 42 | 43 | class HeaderAuthentication(BaseAuthenticationMethod): 44 | """Authentication provided within the header. 45 | 46 | Normally associated with Oauth authorization, in the format: 47 | "Authorization: Bearer " 48 | """ 49 | 50 | def __init__( 51 | self, 52 | token: str, 53 | parameter: str = "Authorization", 54 | scheme: OptionalStr = "Bearer", 55 | extra: Optional[Dict[str, str]] = None, 56 | ): 57 | self._token = token 58 | self._parameter = parameter 59 | self._scheme = scheme 60 | self._extra = extra 61 | 62 | def get_headers(self) -> Dict[str, str]: 63 | if self._scheme: 64 | headers = {self._parameter: f"{self._scheme} {self._token}"} 65 | else: 66 | headers = {self._parameter: self._token} 67 | if self._extra: 68 | headers.update(self._extra) 69 | return headers 70 | 71 | 72 | class BasicAuthentication(BaseAuthenticationMethod): 73 | """Authentication provided in the form of a username/password.""" 74 | 75 | def __init__(self, username: str, password: str): 76 | self._username = username 77 | self._password = password 78 | 79 | def get_username_password_authentication(self) -> BasicAuthType: 80 | return (self._username, self._password) 81 | 82 | 83 | class CookieAuthentication(BaseAuthenticationMethod): 84 | """Authentication stored as Cookie after accessing URL using GET.""" 85 | 86 | def __init__( 87 | self, 88 | auth_url: str, 89 | authentication: Union[HeaderAuthentication, QueryParameterAuthentication, BasicAuthentication], 90 | ): 91 | self._auth_url = auth_url 92 | self._authentication = authentication 93 | 94 | def perform_initial_auth(self, client: "APIClient"): 95 | client.get( 96 | self._auth_url, 97 | headers=self._authentication.get_headers(), 98 | params=self._authentication.get_query_params(), 99 | cookies=http.cookiejar.CookieJar(), 100 | ) 101 | -------------------------------------------------------------------------------- /apiclient/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from copy import copy 3 | from typing import Any, Optional, Type 4 | 5 | from apiclient.authentication_methods import BaseAuthenticationMethod, NoAuthentication 6 | from apiclient.error_handlers import BaseErrorHandler, ErrorHandler 7 | from apiclient.request_formatters import BaseRequestFormatter, NoOpRequestFormatter 8 | from apiclient.request_strategies import BaseRequestStrategy, RequestStrategy 9 | from apiclient.response_handlers import BaseResponseHandler, RequestsResponseHandler 10 | from apiclient.utils.typing import OptionalDict 11 | 12 | LOG = logging.getLogger(__name__) 13 | 14 | # Timeout in seconds (float) 15 | DEFAULT_TIMEOUT = 10.0 16 | 17 | 18 | class APIClient: 19 | def __init__( 20 | self, 21 | authentication_method: Optional[BaseAuthenticationMethod] = None, 22 | response_handler: Type[BaseResponseHandler] = RequestsResponseHandler, 23 | request_formatter: Type[BaseRequestFormatter] = NoOpRequestFormatter, 24 | error_handler: Type[BaseErrorHandler] = ErrorHandler, 25 | request_strategy: Optional[BaseRequestStrategy] = None, 26 | ): 27 | # Set default values 28 | self._default_headers = {} 29 | self._default_query_params = {} 30 | self._default_username_password_authentication = None 31 | # A session needs to live at this client level so that all 32 | # request strategies have access to the same session. 33 | self._session = None 34 | 35 | # Set client strategies 36 | self.set_authentication_method(authentication_method or NoAuthentication()) 37 | self.set_response_handler(response_handler) 38 | self.set_error_handler(error_handler) 39 | self.set_request_formatter(request_formatter) 40 | self.set_request_strategy(request_strategy or RequestStrategy()) 41 | 42 | # Perform any one time authentication required by api 43 | self._authentication_method.perform_initial_auth(self) 44 | 45 | def get_session(self) -> Any: 46 | return self._session 47 | 48 | def set_session(self, session: Any): 49 | self._session = session 50 | 51 | def set_authentication_method(self, authentication_method: BaseAuthenticationMethod): 52 | if not isinstance(authentication_method, BaseAuthenticationMethod): 53 | raise RuntimeError( 54 | "provided authentication_method must be an instance of BaseAuthenticationMethod." 55 | ) 56 | self._authentication_method = authentication_method 57 | 58 | def get_authentication_method(self) -> BaseAuthenticationMethod: 59 | return self._authentication_method 60 | 61 | def get_response_handler(self) -> Type[BaseResponseHandler]: 62 | return self._response_handler 63 | 64 | def set_response_handler(self, response_handler: Type[BaseResponseHandler]): 65 | if not (response_handler and issubclass(response_handler, BaseResponseHandler)): 66 | raise RuntimeError("provided response_handler must be a subclass of BaseResponseHandler.") 67 | self._response_handler = response_handler 68 | 69 | def get_error_handler(self) -> Type[BaseErrorHandler]: 70 | return self._error_handler 71 | 72 | def set_error_handler(self, error_handler: Type[BaseErrorHandler]): 73 | if not (error_handler and issubclass(error_handler, BaseErrorHandler)): 74 | raise RuntimeError("provided error_handler must be a subclass of BaseErrorHandler.") 75 | self._error_handler = error_handler 76 | 77 | def get_request_formatter(self) -> Type[BaseRequestFormatter]: 78 | return self._request_formatter 79 | 80 | def set_request_formatter(self, request_formatter: Type[BaseRequestFormatter]): 81 | if not (request_formatter and issubclass(request_formatter, BaseRequestFormatter)): 82 | raise RuntimeError("provided request_formatter must be a subclass of BaseRequestFormatter.") 83 | self._request_formatter = request_formatter 84 | 85 | def get_request_strategy(self) -> BaseRequestStrategy: 86 | return self._request_strategy 87 | 88 | def set_request_strategy(self, request_strategy: BaseRequestStrategy): 89 | if not isinstance(request_strategy, BaseRequestStrategy): 90 | raise RuntimeError("provided request_strategy must be an instance of BaseRequestStrategy.") 91 | self._request_strategy = request_strategy 92 | self._request_strategy.set_client(self) 93 | 94 | def get_default_headers(self) -> dict: 95 | headers = {} 96 | for strategy in (self._authentication_method, self._request_formatter): 97 | headers.update(strategy.get_headers()) 98 | return headers 99 | 100 | def get_default_query_params(self) -> dict: 101 | return self._authentication_method.get_query_params() 102 | 103 | def get_default_username_password_authentication(self) -> Optional[tuple]: 104 | return self._authentication_method.get_username_password_authentication() 105 | 106 | def get_request_timeout(self) -> float: 107 | """Return the number of seconds before the request times out.""" 108 | return DEFAULT_TIMEOUT 109 | 110 | def clone(self): 111 | """Enable Prototype pattern on client.""" 112 | return copy(self) 113 | 114 | def post(self, endpoint: str, data: dict, params: OptionalDict = None, **kwargs): 115 | """Send data and return response data from POST endpoint.""" 116 | LOG.debug("POST %s with %s", endpoint, data) 117 | return self.get_request_strategy().post(endpoint, data=data, params=params, **kwargs) 118 | 119 | def get(self, endpoint: str, params: OptionalDict = None, **kwargs): 120 | """Return response data from GET endpoint.""" 121 | LOG.debug("GET %s", endpoint) 122 | return self.get_request_strategy().get(endpoint, params=params, **kwargs) 123 | 124 | def put(self, endpoint: str, data: dict, params: OptionalDict = None, **kwargs): 125 | """Send data to overwrite resource and return response data from PUT endpoint.""" 126 | LOG.debug("PUT %s with %s", endpoint, data) 127 | return self.get_request_strategy().put(endpoint, data=data, params=params, **kwargs) 128 | 129 | def patch(self, endpoint: str, data: dict, params: OptionalDict = None, **kwargs): 130 | """Send data to update resource and return response data from PATCH endpoint.""" 131 | LOG.debug("PATCH %s with %s", endpoint, data) 132 | return self.get_request_strategy().patch(endpoint, data=data, params=params, **kwargs) 133 | 134 | def delete(self, endpoint: str, params: OptionalDict = None, **kwargs): 135 | """Remove resource with DELETE endpoint.""" 136 | LOG.debug("DELETE %s", endpoint) 137 | return self.get_request_strategy().delete(endpoint, params=params, **kwargs) 138 | -------------------------------------------------------------------------------- /apiclient/decorates.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | 4 | def endpoint(cls_=None, base_url=None): 5 | """Decorator for automatically constructing urls from a base_url and defined resources.""" 6 | 7 | def wrap(cls): 8 | return _process_class(cls, base_url) 9 | 10 | if cls_ is None: 11 | # Decorator is called as @endpoint with parens. 12 | return wrap 13 | # Decorator is called as @endpoint without parens. 14 | return wrap(cls_) 15 | 16 | 17 | def _process_class(cls, base_url): 18 | if base_url is None: 19 | raise RuntimeError( 20 | "A decorated endpoint must define a base_url as @endpoint(base_url='http://foo.com')." 21 | ) 22 | else: 23 | base_url = base_url.rstrip("/") 24 | 25 | for name, value in inspect.getmembers(cls): 26 | if name.startswith("_") or inspect.ismethod(value) or inspect.isfunction(value): 27 | # Ignore any private or class attributes. 28 | continue 29 | new_value = str(value).lstrip("/") 30 | resource = f"{base_url}/{new_value}" 31 | setattr(cls, name, resource) 32 | return cls 33 | -------------------------------------------------------------------------------- /apiclient/error_handlers.py: -------------------------------------------------------------------------------- 1 | from apiclient import exceptions 2 | from apiclient.response import Response 3 | 4 | 5 | class BaseErrorHandler: 6 | """Translates a response into an apiclient exception.""" 7 | 8 | @staticmethod 9 | def get_exception(response: Response) -> exceptions.APIRequestError: 10 | raise NotImplementedError 11 | 12 | 13 | class ErrorHandler(BaseErrorHandler): 14 | @staticmethod 15 | def get_exception(response: Response) -> exceptions.APIRequestError: 16 | status_code = response.get_status_code() 17 | exception_class = exceptions.UnexpectedError 18 | 19 | if 300 <= status_code < 400: 20 | exception_class = exceptions.RedirectionError 21 | elif 400 <= status_code < 500: 22 | exception_class = exceptions.ClientError 23 | elif 500 <= status_code < 600: 24 | exception_class = exceptions.ServerError 25 | 26 | return exception_class( 27 | message=( 28 | f"{status_code} Error: {response.get_status_reason()} " 29 | f"for url: {response.get_requested_url()}" 30 | ), 31 | status_code=status_code, 32 | info=response.get_raw_data(), 33 | ) 34 | -------------------------------------------------------------------------------- /apiclient/exceptions.py: -------------------------------------------------------------------------------- 1 | from apiclient.utils.typing import OptionalInt 2 | 3 | 4 | class APIClientError(Exception): 5 | """General exception to denote that something went wrong when using the client. 6 | 7 | All other exceptions *must* inherit from this.""" 8 | 9 | pass 10 | 11 | 12 | class ResponseParseError(APIClientError): 13 | """Something went wrong when trying to parse the response.""" 14 | 15 | pass 16 | 17 | 18 | class APIRequestError(APIClientError): 19 | """Exception to denote that something went wrong when making the request.""" 20 | 21 | message: str = "" 22 | status_code: OptionalInt = None 23 | info: str = "" 24 | 25 | def __init__(self, message: str = "", status_code: OptionalInt = None, info: str = ""): 26 | self.message = self.message or message 27 | self.status_code = self.status_code or status_code 28 | self.info = self.info or info 29 | 30 | def __str__(self): 31 | return self.message 32 | 33 | 34 | class RedirectionError(APIRequestError): 35 | """A redirection status code (3xx) was returned as a final code when making the request. 36 | 37 | This means that no data can be returned to the client as we could not find the 38 | requested resource as it had moved. 39 | """ 40 | 41 | pass 42 | 43 | 44 | class ClientError(APIRequestError): 45 | """A client error status code (4xx) was returned as a final code when making the request. 46 | 47 | This is due primarily to user input by passing invalid data to the API 48 | """ 49 | 50 | pass 51 | 52 | 53 | class ServerError(APIRequestError): 54 | """A server error status code (5xx) was returned as a final code when making the request. 55 | 56 | This will mainly due to the server being uncontactable when making the request. 57 | """ 58 | 59 | pass 60 | 61 | 62 | class UnexpectedError(APIRequestError): 63 | """An unexpected error occurred when making the request.""" 64 | 65 | pass 66 | -------------------------------------------------------------------------------- /apiclient/paginators.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from functools import wraps 3 | from typing import Callable 4 | 5 | from apiclient.client import APIClient 6 | from apiclient.request_strategies import ( 7 | BaseRequestStrategy, 8 | QueryParamPaginatedRequestStrategy, 9 | UrlPaginatedRequestStrategy, 10 | ) 11 | 12 | 13 | @contextmanager 14 | def set_strategy(client: APIClient, strategy: BaseRequestStrategy): 15 | """Set a strategy on the client and then set it back after running.""" 16 | temporary_client = client.clone() 17 | temporary_client.set_request_strategy(strategy) 18 | try: 19 | yield temporary_client 20 | finally: 21 | del temporary_client 22 | 23 | 24 | def paginated(by_query_params: Callable = None, by_url: Callable = None): 25 | """Decorator to signal that the page is paginated.""" 26 | if by_query_params: 27 | strategy = QueryParamPaginatedRequestStrategy(by_query_params) 28 | else: 29 | strategy = UrlPaginatedRequestStrategy(by_url) 30 | 31 | def decorator(func): 32 | @wraps(func) 33 | def wrap(client: APIClient, *args, **kwargs): 34 | with set_strategy(client, strategy) as temporary_client: 35 | return func(temporary_client, *args, **kwargs) 36 | 37 | return wrap 38 | 39 | return decorator 40 | -------------------------------------------------------------------------------- /apiclient/request_formatters.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from apiclient.utils.typing import OptionalJsonType, OptionalStr 4 | 5 | 6 | class BaseRequestFormatter: 7 | """Format the outgoing data accordingly and set the content-type headers.""" 8 | 9 | content_type = None 10 | 11 | @classmethod 12 | def get_headers(cls) -> dict: 13 | if cls.content_type: 14 | return {"Content-type": cls.content_type} 15 | else: 16 | return {} 17 | 18 | @classmethod 19 | def format(cls, data: OptionalJsonType): 20 | raise NotImplementedError 21 | 22 | 23 | class NoOpRequestFormatter(BaseRequestFormatter): 24 | """No action request formatter.""" 25 | 26 | @classmethod 27 | def format(cls, data: OptionalJsonType) -> OptionalJsonType: 28 | return data 29 | 30 | 31 | class JsonRequestFormatter(BaseRequestFormatter): 32 | """Format the outgoing data as json.""" 33 | 34 | content_type = "application/json" 35 | 36 | @classmethod 37 | def format(cls, data: OptionalJsonType) -> OptionalStr: 38 | if data: 39 | return json.dumps(data) 40 | -------------------------------------------------------------------------------- /apiclient/request_strategies.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import TYPE_CHECKING, Callable 3 | 4 | import requests 5 | 6 | from apiclient.exceptions import UnexpectedError 7 | from apiclient.response import RequestsResponse, Response 8 | from apiclient.utils.typing import OptionalDict 9 | 10 | if TYPE_CHECKING: # pragma: no cover 11 | # Stupid way of getting around cyclic imports when 12 | # using typehinting. 13 | from apiclient import APIClient 14 | 15 | 16 | class BaseRequestStrategy: 17 | def set_client(self, client: "APIClient"): 18 | self._client = client 19 | 20 | def get_client(self) -> "APIClient": 21 | return self._client 22 | 23 | def post(self, *args, **kwargs): # pragma: no cover 24 | raise NotImplementedError 25 | 26 | def get(self, *args, **kwargs): # pragma: no cover 27 | raise NotImplementedError 28 | 29 | def put(self, *args, **kwargs): # pragma: no cover 30 | raise NotImplementedError 31 | 32 | def patch(self, *args, **kwargs): # pragma: no cover 33 | raise NotImplementedError 34 | 35 | def delete(self, *args, **kwargs): # pragma: no cover 36 | raise NotImplementedError 37 | 38 | 39 | class RequestStrategy(BaseRequestStrategy): 40 | """Requests strategy that uses the `requests` lib with a `requests.session`.""" 41 | 42 | def set_client(self, client: "APIClient"): 43 | super().set_client(client) 44 | # Set a global `requests.session` on the parent client instance. 45 | if self.get_session() is None: 46 | self.set_session(requests.session()) 47 | 48 | def get_session(self): 49 | return self.get_client().get_session() 50 | 51 | def set_session(self, session: requests.Session): 52 | self.get_client().set_session(session) 53 | 54 | def post(self, endpoint: str, data: dict, params: OptionalDict = None, **kwargs): 55 | """Send data and return response data from POST endpoint.""" 56 | return self._make_request(self.get_session().post, endpoint, data=data, params=params, **kwargs) 57 | 58 | def get(self, endpoint: str, params: OptionalDict = None, **kwargs): 59 | """Return response data from GET endpoint.""" 60 | return self._make_request(self.get_session().get, endpoint, params=params, **kwargs) 61 | 62 | def put(self, endpoint: str, data: dict, params: OptionalDict = None, **kwargs): 63 | """Send data to overwrite resource and return response data from PUT endpoint.""" 64 | return self._make_request(self.get_session().put, endpoint, data=data, params=params, **kwargs) 65 | 66 | def patch(self, endpoint: str, data: dict, params: OptionalDict = None, **kwargs): 67 | """Send data to update resource and return response data from PATCH endpoint.""" 68 | return self._make_request(self.get_session().patch, endpoint, data=data, params=params, **kwargs) 69 | 70 | def delete(self, endpoint: str, params: OptionalDict = None, **kwargs): 71 | """Remove resource with DELETE endpoint.""" 72 | return self._make_request(self.get_session().delete, endpoint, params=params, **kwargs) 73 | 74 | def _make_request( 75 | self, 76 | request_method: Callable, 77 | endpoint: str, 78 | params: OptionalDict = None, 79 | headers: OptionalDict = None, 80 | data: OptionalDict = None, 81 | **kwargs, 82 | ) -> Response: 83 | """Make the request with the given method. 84 | 85 | Delegates response parsing to the response handler. 86 | """ 87 | try: 88 | response = RequestsResponse( 89 | request_method( 90 | endpoint, 91 | params=self._get_request_params(params), 92 | headers=self._get_request_headers(headers), 93 | auth=self._get_username_password_authentication(), 94 | data=self._get_formatted_data(data), 95 | timeout=self._get_request_timeout(), 96 | **kwargs, 97 | ) 98 | ) 99 | except Exception as error: 100 | raise UnexpectedError(f"Error when contacting '{endpoint}'") from error 101 | else: 102 | self._check_response(response) 103 | return self._decode_response_data(response) 104 | 105 | def _get_request_params(self, params: OptionalDict) -> dict: 106 | """Return dictionary with any additional authentication query parameters.""" 107 | if params is None: 108 | params = {} 109 | params.update(self.get_client().get_default_query_params()) 110 | return params 111 | 112 | def _get_request_headers(self, headers: OptionalDict) -> dict: 113 | """Return dictionary with any additional authentication headers.""" 114 | if headers is None: 115 | headers = {} 116 | headers.update(self.get_client().get_default_headers()) 117 | return headers 118 | 119 | def _get_username_password_authentication(self): 120 | return self.get_client().get_default_username_password_authentication() 121 | 122 | def _get_formatted_data(self, data: OptionalDict): 123 | return self.get_client().get_request_formatter().format(data) 124 | 125 | def _get_request_timeout(self) -> float: 126 | """Return the number of seconds before the request times out.""" 127 | return self.get_client().get_request_timeout() 128 | 129 | def _check_response(self, response: Response): 130 | """Raise a custom exception if the response is not OK.""" 131 | status_code = response.get_status_code() 132 | if status_code < 200 or status_code >= 300: 133 | self._handle_bad_response(response) 134 | 135 | def _decode_response_data(self, response: Response): 136 | return self.get_client().get_response_handler().get_request_data(response) 137 | 138 | def _handle_bad_response(self, response: Response): 139 | """Convert the error into an understandable client exception.""" 140 | raise self.get_client().get_error_handler().get_exception(response) 141 | 142 | 143 | class QueryParamPaginatedRequestStrategy(RequestStrategy): 144 | """Strategy for GET requests where pages are defined in query params.""" 145 | 146 | def __init__(self, next_page: Callable): 147 | self._next_page = next_page 148 | 149 | def get(self, endpoint: str, params: OptionalDict = None, **kwargs): 150 | if params is None: 151 | params = {} 152 | 153 | pages = [] 154 | run = True 155 | while run: 156 | this_page_params = deepcopy(params) 157 | 158 | response = super().get(endpoint, params=this_page_params, **kwargs) 159 | 160 | pages.append(response) 161 | next_page_params = self.get_next_page_params(response, previous_page_params=this_page_params) 162 | 163 | if next_page_params: 164 | params.update(next_page_params) 165 | else: 166 | # No further pages found 167 | run = False 168 | 169 | return pages 170 | 171 | def get_next_page_params(self, response, previous_page_params: dict) -> OptionalDict: 172 | return self._next_page(response, previous_page_params) 173 | 174 | 175 | class UrlPaginatedRequestStrategy(RequestStrategy): 176 | """Strategy for GET requests where pages are specified by updating the endpoint.""" 177 | 178 | def __init__(self, next_page: Callable): 179 | self._next_page = next_page 180 | 181 | def get(self, endpoint: str, params: OptionalDict = None, **kwargs): 182 | pages = [] 183 | while endpoint: 184 | response = super().get(endpoint, params=params, **kwargs) 185 | 186 | pages.append(response) 187 | 188 | next_page_url = self.get_next_page_url(response, previous_page_url=endpoint) 189 | endpoint = next_page_url 190 | 191 | return pages 192 | 193 | def get_next_page_url(self, response, previous_page_url: str) -> OptionalDict: 194 | return self._next_page(response, previous_page_url) 195 | -------------------------------------------------------------------------------- /apiclient/response.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import requests 4 | 5 | from apiclient.utils.typing import JsonType 6 | 7 | 8 | class Response: 9 | """ 10 | Response abstracts away the underlying response, allowing 11 | us to pass around an interface representing the response 12 | without any need to knowledge of the underlying http 13 | implementation.""" 14 | 15 | def get_original(self) -> Any: 16 | """Returns the original underlying response object.""" 17 | raise NotImplementedError 18 | 19 | def get_status_code(self) -> int: 20 | """Returns the status code of the response.""" 21 | raise NotImplementedError 22 | 23 | def get_raw_data(self) -> str: 24 | """Returns the content of the response, in unicode.""" 25 | raise NotImplementedError 26 | 27 | def get_json(self) -> JsonType: 28 | """Returns the json-encoded content of a response.""" 29 | raise NotImplementedError 30 | 31 | def get_status_reason(self) -> str: 32 | """Returns the textual representation of an HTTP status code, e.g. "NOT FOUND" or "OK".""" 33 | raise NotImplementedError 34 | 35 | def get_requested_url(self) -> str: 36 | """Returns the url to which the request was made.""" 37 | raise NotImplementedError 38 | 39 | 40 | class RequestsResponse(Response): 41 | """Implementation of the response for a requests.response type.""" 42 | 43 | def __init__(self, response: requests.Response): 44 | self._response = response 45 | 46 | def get_original(self) -> Any: 47 | return self._response 48 | 49 | def get_status_code(self) -> int: 50 | return self._response.status_code 51 | 52 | def get_raw_data(self) -> str: 53 | return self._response.text 54 | 55 | def get_json(self) -> JsonType: 56 | return self._response.json() 57 | 58 | def get_status_reason(self) -> str: 59 | if not self._response.reason: 60 | return "" 61 | return self._response.reason 62 | 63 | def get_requested_url(self) -> str: 64 | return self._response.url 65 | -------------------------------------------------------------------------------- /apiclient/response_handlers.py: -------------------------------------------------------------------------------- 1 | from json import JSONDecodeError 2 | from typing import Optional 3 | from xml.etree import ElementTree 4 | 5 | import requests 6 | 7 | from apiclient.exceptions import ResponseParseError 8 | from apiclient.response import Response 9 | from apiclient.utils.typing import JsonType, XmlType 10 | 11 | 12 | class BaseResponseHandler: 13 | """Parses the response according to the strategy set.""" 14 | 15 | @staticmethod 16 | def get_request_data(response: Response): 17 | raise NotImplementedError 18 | 19 | 20 | class RequestsResponseHandler(BaseResponseHandler): 21 | """Return the original requests response.""" 22 | 23 | @staticmethod 24 | def get_request_data(response: Response) -> requests.Response: 25 | return response.get_original() 26 | 27 | 28 | class JsonResponseHandler(BaseResponseHandler): 29 | """Attempt to return the decoded response data as json.""" 30 | 31 | @staticmethod 32 | def get_request_data(response: Response) -> Optional[JsonType]: 33 | if response.get_raw_data() == "": 34 | return None 35 | 36 | try: 37 | response_json = response.get_json() 38 | except JSONDecodeError as error: 39 | raise ResponseParseError( 40 | f"Unable to decode response data to json. data='{response.get_raw_data()}'" 41 | ) from error 42 | return response_json 43 | 44 | 45 | class XmlResponseHandler(BaseResponseHandler): 46 | """Attempt to return the decoded response to an xml Element.""" 47 | 48 | @staticmethod 49 | def get_request_data(response: Response) -> Optional[XmlType]: 50 | if response.get_raw_data() == "": 51 | return None 52 | 53 | try: 54 | xml_element = ElementTree.fromstring(response.get_raw_data()) 55 | except ElementTree.ParseError as error: 56 | raise ResponseParseError( 57 | f"Unable to parse response data to xml. data='{response.get_raw_data()}'" 58 | ) from error 59 | return xml_element 60 | -------------------------------------------------------------------------------- /apiclient/retrying.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import List 3 | 4 | import tenacity 5 | 6 | from apiclient.exceptions import APIRequestError 7 | 8 | 9 | class retry_if_api_request_error(tenacity.retry_if_exception): 10 | """Retry strategy that retries if an exception is an APIRequestError and meets the criteria. 11 | 12 | * Exceptions not derived from APIRequestError will not be retried. 13 | * APIRequestError with no status code will be retried by default as 14 | this indicates that we were not able to establish a connection and 15 | can be safely retried. 16 | * when passed a list of status codes, if the raised status code is in the 17 | list then the request will be retried. 18 | * status codes >= 500 codes will be retried. 19 | """ 20 | 21 | def __init__(self, status_codes: List[int] = None): 22 | self._status_codes = status_codes 23 | super().__init__(self._retry_if) 24 | 25 | def _retry_if(self, error): 26 | if not isinstance(error, APIRequestError): 27 | return False 28 | if error.status_code is None: 29 | return True 30 | if self._status_codes: 31 | return error.status_code in self._status_codes 32 | # Fallback - 500 status codes are usually safe to retry. 33 | return error.status_code >= 500 34 | 35 | 36 | class wait_exponential_jitter(tenacity.wait_exponential): 37 | """Wait strategy that applies exponential backoff with jitter.""" 38 | 39 | def __call__(self, retry_state): 40 | high = super().__call__(retry_state) 41 | low = high * 0.75 42 | return low + (random.random() * (high - low)) 43 | 44 | 45 | # Leverage tenacity to provide a simple decorator that will retry 46 | # exponentially (with some randomness), with a maximum wait time of 47 | # 30 seconds. Retrying will stop after 5 minutes. 48 | retry_request = tenacity.retry( 49 | retry=retry_if_api_request_error(), 50 | wait=wait_exponential_jitter(multiplier=0.25, max=30), 51 | stop=tenacity.stop_after_delay(300), 52 | reraise=True, 53 | ) 54 | -------------------------------------------------------------------------------- /apiclient/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeWooster/api-client/380c28c9d28f05139e4d41aab1ea7d2729cbbbe1/apiclient/utils/__init__.py -------------------------------------------------------------------------------- /apiclient/utils/typing.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple, Union 2 | from xml.etree import ElementTree 3 | 4 | from requests import Response 5 | 6 | OptionalDict = Optional[dict] 7 | OptionalStr = Optional[str] 8 | OptionalInt = Optional[int] 9 | BasicAuthType = Tuple[str, str] 10 | JsonType = Union[str, list, dict] 11 | OptionalJsonType = Optional[JsonType] 12 | XmlType = ElementTree.Element 13 | ResponseType = Union[JsonType, XmlType, Response] 14 | -------------------------------------------------------------------------------- /apiclient/utils/warnings.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | 4 | def deprecation_warning(message: str): 5 | """Emit a deprecation warning.""" 6 | warnings.warn(f"[APIClient] {message}", DeprecationWarning) 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length=109 3 | py36=true 4 | -------------------------------------------------------------------------------- /readme-data/jetbrains.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 43 | 45 | 47 | 48 | 51 | 54 | 56 | 57 | 59 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /scripts/dep_checker.py: -------------------------------------------------------------------------------- 1 | """ 2 | Checks tested dependencies against latest releases to ensure that the 3 | tox matrix includes all latest versions. 4 | 5 | A system exit is a valid way to exit this script and indicates a failure. 6 | """ 7 | import subprocess 8 | from dataclasses import dataclass 9 | from typing import Dict, Union, Optional, List 10 | from packaging import version 11 | 12 | 13 | @dataclass 14 | class Package: 15 | name: str 16 | current: Union[version.LegacyVersion, version.Version] 17 | latest: Union[version.LegacyVersion, version.Version] 18 | 19 | 20 | @dataclass 21 | class ToxDep: 22 | name: str 23 | gte: Union[version.LegacyVersion, version.Version] 24 | lt: Union[version.LegacyVersion, version.Version] 25 | 26 | 27 | @dataclass 28 | class ToxEnv: 29 | name: str 30 | deps: List[ToxDep] 31 | 32 | 33 | @dataclass 34 | class ToxFile: 35 | envs: List[ToxEnv] 36 | 37 | 38 | def main(): 39 | packages = get_current_packages() 40 | toxfile = readtox() 41 | 42 | deps = ["requests", "tenacity"] 43 | for dep in deps: 44 | check_version(packages[dep], toxfile) 45 | 46 | 47 | def get_current_packages() -> Dict[str, Package]: 48 | # Get all currently installed packages and the latest version available on pypi. 49 | packages = get_outdated_packages() 50 | packages.update(get_uptodate_packages()) 51 | return packages 52 | 53 | 54 | def check_version(package: Package, toxfile: ToxFile): 55 | print(f"checking package is covered in tox file: {package}") 56 | if not version_in_tox_file(package, toxfile): 57 | msg = f"latest version of package not in tox file: {package.name} - version {package.latest}" 58 | print(msg) 59 | raise SystemExit(msg) 60 | 61 | 62 | def version_in_tox_file(package: Package, toxfile: ToxFile) -> bool: 63 | covered = False 64 | 65 | for env in toxfile.envs: 66 | for dep in env.deps: 67 | if dep.name != package.name: 68 | continue 69 | 70 | if package.latest >= dep.gte and package.latest < dep.lt: 71 | covered = True 72 | 73 | return covered 74 | 75 | 76 | def readtox() -> ToxFile: 77 | envs: List[ToxEnv] = [] 78 | resp = run("tox --showconfig") 79 | for env in resp.decode("utf-8").split("\n\n"): 80 | envs.append(parse_toxenv(env)) 81 | return ToxFile(envs=envs) 82 | 83 | 84 | def parse_toxenv(text: str) -> ToxEnv: 85 | env = ToxEnv(name="", deps=[]) 86 | 87 | for line in text.split("\n"): 88 | if line.startswith("[") and line.endswith("]"): 89 | env.name = line 90 | 91 | if line.startswith("deps"): 92 | env.deps = parse_toxdeps(line) 93 | 94 | return env 95 | 96 | 97 | def parse_toxdeps(text: str) -> List[ToxDep]: 98 | toxdeps = [] 99 | 100 | _, deps = text.split("=", 1) 101 | for dep in deps.strip().rstrip("]").lstrip("[").split(", "): 102 | try: 103 | dep = extractdep(dep) 104 | except Exception as err: 105 | print(f"cannot parse: {dep}") 106 | continue 107 | toxdeps.append(dep) 108 | 109 | return toxdeps 110 | 111 | 112 | def extractdep(dep: str) -> ToxDep: 113 | parts = dep.replace(">", "=").replace("<", "=").split("=") 114 | name = parts[0] 115 | gte = parts[2].strip(" ,") 116 | lt = parts[3].strip(" ,") 117 | return ToxDep(name=name, gte=version.parse(gte), lt=version.parse(lt)) 118 | 119 | 120 | def get_outdated_packages() -> Dict[str, Package]: 121 | packages = {} 122 | 123 | resp = run("pip list -o") 124 | 125 | for line in resp.decode("utf-8").split("\n"): 126 | try: 127 | package = _parse_outdated_version_line(line) 128 | except Exception as err: 129 | print(f"unable to parse version for package: {line}") 130 | continue 131 | 132 | packages[package.name] = package 133 | 134 | return packages 135 | 136 | 137 | def _parse_outdated_version_line(line: str) -> Optional[Package]: 138 | name, current, latest, _ = line.split() 139 | return Package(name=name, current=version.parse(current), latest=version.parse(latest)) 140 | 141 | 142 | def get_uptodate_packages() -> Dict[str, Package]: 143 | packages = {} 144 | 145 | resp = run("pip list -u") 146 | 147 | for line in resp.decode("utf-8").split("\n"): 148 | try: 149 | package = _parse_uptodate_version_line(line) 150 | except Exception as err: 151 | print(f"unable to parse version for package: {line}") 152 | continue 153 | 154 | packages[package.name] = package 155 | 156 | return packages 157 | 158 | 159 | def _parse_uptodate_version_line(line: str) -> Optional[Package]: 160 | name, current = line.split() 161 | # In this case, the current == latest versions 162 | return Package(name=name, current=version.parse(current), latest=version.parse(current)) 163 | 164 | 165 | def run(command: str) -> bytes: 166 | return subprocess.check_output(command.split()) 167 | 168 | 169 | if __name__ == "__main__": 170 | main() 171 | -------------------------------------------------------------------------------- /scripts/generate_changelog.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generate a new changelog entry from commits since the last entry. 3 | """ 4 | # TODO: create this script 5 | 6 | 7 | def main(): 8 | pass 9 | 10 | 11 | if __name__ == "__main__": 12 | main() 13 | -------------------------------------------------------------------------------- /scripts/update_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Automate the generation of release candidate versions 3 | for rc/ named branches. 4 | """ 5 | import subprocess 6 | import sys 7 | from typing import List, Optional, Tuple 8 | 9 | VERSION_FILE = "VERSION" 10 | 11 | 12 | def main(github_ref: str): 13 | major, minor, patch, rc = extract_version_from_ref(github_ref) 14 | print(f"tagged version: {fmt_version(major, minor, patch, rc)}") 15 | versions = get_all_versions() 16 | 17 | if (major, minor, patch, rc) in versions: 18 | raise SystemExit( 19 | f"Unable to upload new version. version already exists: '{fmt_version(major, minor, patch, rc)}'" 20 | ) 21 | cur_version = get_version() 22 | if cur_version != f"{major}.{minor}.{patch}": 23 | raise SystemExit( 24 | "Cannot upload tag version. it does not match the current version of the project: " 25 | f"current_version = {cur_version}, tag_version = {fmt_version(major, minor, patch)}" 26 | ) 27 | if rc > 0: 28 | print("Tagged as RC version. Updating version file.") 29 | update_rc_version(rc) 30 | 31 | print(f"Version in VERSION file: {read_version()}") 32 | 33 | 34 | def fmt_version(major: int, minor: int, patch: int, rc: int = 0): 35 | if rc > 0: 36 | return f"{major}.{minor}.{patch}" 37 | return f"{major}.{minor}.{patch}rc{rc}" 38 | 39 | 40 | def extract_version_from_ref(ref: str) -> Tuple[int, int, int, int]: 41 | err = ( 42 | f"invalid github ref, expecting in format `refs/tags/v..[rc]`, got: '{ref}'" 43 | ) 44 | try: 45 | title, subtitle, version = ref.split("/") 46 | except ValueError: 47 | raise SystemExit(err) 48 | 49 | if title != "refs" or subtitle != "tags" or not version.startswith("v"): 50 | raise SystemExit(err) 51 | 52 | try: 53 | major, minor, patch, rc = split_ver(version[1:]) 54 | except ValueError: 55 | raise SystemExit(err) 56 | 57 | return major, minor, patch, rc 58 | 59 | 60 | def get_current_branch() -> str: 61 | p = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True) 62 | if p.returncode != 0: 63 | raise SystemExit("Unable to determine current git branch name") 64 | return p.stdout.decode("utf-8").strip() 65 | 66 | 67 | def update_rc_version(rc_version): 68 | # Extract the first 8 chars of the git hash and create a new rc version 69 | version = get_version() 70 | update_version_file(version, rc_version) 71 | 72 | 73 | def get_all_versions() -> List[Tuple[int, int, int, int]]: 74 | p = subprocess.run(["pip", "search", "api-client"], capture_output=True) 75 | if p.returncode != 0: 76 | raise SystemExit("Unable to determine current git branch name") 77 | 78 | # A list to contain the existing rc versions deployed 79 | versions = [] 80 | 81 | resp = p.stdout.decode("utf-8").strip() 82 | for line in resp.split("\n"): 83 | elems = line.split() 84 | if elems[0] != "api-client": 85 | continue 86 | version = elems[1].strip("()") 87 | 88 | versions.append(split_ver(version)) 89 | 90 | return versions 91 | 92 | 93 | def split_ver(version: str) -> Tuple[int, int, int, int]: 94 | major, minor, patch = version.split(".") 95 | if "rc" in patch: 96 | patch, rc = patch.split("rc") 97 | else: 98 | rc = 0 99 | return int(major), int(minor), int(patch), int(rc) 100 | 101 | 102 | def get_version() -> str: 103 | v = read_version() 104 | 105 | # version is symantic. Need 3 parts and last part must be an integer 106 | # otherwise we cant update the version. 107 | parts = v.split(".") 108 | if len(parts) != 3 or not parts[2].isnumeric(): 109 | raise SystemExit(f"version is invalid: '{v}'") 110 | return v 111 | 112 | 113 | def read_version() -> str: 114 | with open(VERSION_FILE, "r") as buf: 115 | v = buf.read() 116 | return v 117 | 118 | 119 | def update_version_file(version: str, rc_ver: int): 120 | with open(VERSION_FILE, "w") as buf: 121 | buf.write(f"{version}rc{rc_ver}") 122 | 123 | 124 | if __name__ == "__main__": 125 | sys.exit(main(github_ref=sys.argv[1])) 126 | -------------------------------------------------------------------------------- /scripts/upload_new_package.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyPi package uploader that doesn't return a bad status code 3 | if the package already exists. 4 | """ 5 | import sys 6 | 7 | from requests import HTTPError 8 | from twine.cli import dispatch 9 | 10 | VERSION_FILE = "VERSION" 11 | 12 | 13 | def main(): 14 | print("Uploading to pypi") 15 | upload_to_pypi() 16 | 17 | 18 | def upload_to_pypi(): 19 | try: 20 | return dispatch(["upload", "dist/*"]) 21 | except HTTPError as error: 22 | handle_http_error(error) 23 | 24 | 25 | def handle_http_error(error: HTTPError): 26 | try: 27 | if error.response.status_code == 400: 28 | print(error) 29 | else: 30 | raise error 31 | except Exception: 32 | raise error 33 | 34 | 35 | if __name__ == "__main__": 36 | sys.exit(main()) 37 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = --cov=apiclient/ --cov-fail-under=100 --cov-report html 3 | env = 4 | ENDPOINT_BASE_URL=http://environment.com 5 | 6 | [coverage:report] 7 | fail_under = 100 8 | skip_covered = True 9 | 10 | [isort] 11 | multi_line_output=3 12 | include_trailing_comma=true 13 | force_grid_wrap=0 14 | use_parentheses=true 15 | line_length=109 16 | known_first_party=apiclient,tests 17 | no_lines_before=STDLIB,LOCALFOLDER 18 | default_section=THIRDPARTY 19 | 20 | [flake8] 21 | max_line_length = 109 22 | max_complexity = 10 23 | select = C,E,F,W,B 24 | exclude=apiclient/__init__.py 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import setuptools 4 | 5 | # Pinning tenacity as the api has changed slightly which breaks all tests. 6 | application_dependencies = ["requests>=2.16", "tenacity>=5.1.0"] 7 | prod_dependencies = [] 8 | test_dependencies = ["pytest", "pytest-env", "pytest-cov", "vcrpy", "requests-mock"] 9 | lint_dependencies = ["flake8", "flake8-docstrings", "black", "isort"] 10 | docs_dependencies = [] 11 | dev_dependencies = test_dependencies + lint_dependencies + docs_dependencies + ["ipdb"] 12 | deploy_dependencies = ["requests", "twine"] 13 | 14 | 15 | with open("README.md", "r") as fh: 16 | long_description = fh.read() 17 | 18 | 19 | with open("VERSION", "r") as buf: 20 | version = buf.read() 21 | 22 | 23 | setuptools.setup( 24 | name="api-client", 25 | version=version, 26 | description="Separate the high level client implementation from the underlying CRUD.", 27 | long_description=long_description, 28 | long_description_content_type="text/markdown", 29 | author="Mike Wooster", 30 | author_email="", 31 | url="https://github.com/MikeWooster/api-client", 32 | python_requires=">=3.6", 33 | packages=["apiclient"], 34 | classifiers=[ 35 | "Development Status :: 5 - Production/Stable", 36 | "Programming Language :: Python :: 3.8", 37 | "License :: OSI Approved :: MIT License", 38 | "Operating System :: OS Independent", 39 | "Intended Audience :: Developers", 40 | ], 41 | install_requires=application_dependencies, 42 | extras_require={ 43 | "production": prod_dependencies, 44 | "test": test_dependencies, 45 | "lint": lint_dependencies, 46 | "docs": dev_dependencies, 47 | "dev": dev_dependencies, 48 | "deploy": deploy_dependencies, 49 | }, 50 | include_package_data=True, 51 | zip_safe=False, 52 | ) 53 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeWooster/api-client/380c28c9d28f05139e4d41aab1ea7d2729cbbbe1/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import NamedTuple 3 | from unittest.mock import Mock, sentinel 4 | 5 | import pytest 6 | import requests 7 | import requests_mock 8 | import vcr 9 | 10 | from apiclient import APIClient 11 | from apiclient.request_formatters import BaseRequestFormatter 12 | from apiclient.response_handlers import BaseResponseHandler 13 | 14 | BASE_DIR = os.path.abspath(os.path.realpath(os.path.dirname(__file__))) 15 | VCR_CASSETTE_DIR = os.path.join(BASE_DIR, "vcr_cassettes") 16 | 17 | 18 | api_client_vcr = vcr.VCR( 19 | serializer="yaml", 20 | cassette_library_dir=VCR_CASSETTE_DIR, 21 | record_mode="once", 22 | match_on=["uri", "method", "query"], 23 | ) 24 | 25 | error_cassette_vcr = vcr.VCR( 26 | serializer="yaml", cassette_library_dir=VCR_CASSETTE_DIR, record_mode="once", match_on=["uri"] 27 | ) 28 | 29 | 30 | @pytest.fixture 31 | def cassette(): 32 | with api_client_vcr.use_cassette("cassette.yaml") as cassette: 33 | yield cassette 34 | 35 | 36 | @pytest.fixture 37 | def error_cassette(): 38 | with error_cassette_vcr.use_cassette("error_cassette.yaml") as cassette: 39 | yield cassette 40 | 41 | 42 | @pytest.fixture 43 | def mock_requests() -> requests_mock.Mocker: 44 | with requests_mock.mock() as _mocker: 45 | yield _mocker 46 | 47 | 48 | class MockClient(NamedTuple): 49 | client: Mock 50 | request_formatter: Mock 51 | response_handler: Mock 52 | 53 | 54 | @pytest.fixture 55 | def mock_client(): 56 | # Build our fully mocked client 57 | _mock_client: APIClient = Mock(spec=APIClient) 58 | mock_request_formatter: BaseRequestFormatter = Mock(spec=BaseRequestFormatter) 59 | mock_response_handler: BaseResponseHandler = Mock(spec=BaseResponseHandler) 60 | _mock_client.get_default_query_params.return_value = {} 61 | _mock_client.get_default_headers.return_value = {} 62 | _mock_client.get_default_username_password_authentication.return_value = None 63 | _mock_client.get_request_timeout.return_value = 30.0 64 | _mock_client.get_session.return_value = requests.session() 65 | mock_request_formatter.format.return_value = {} 66 | _mock_client.get_request_formatter.return_value = mock_request_formatter 67 | mock_response_handler.get_request_data.return_value = sentinel.result 68 | _mock_client.get_response_handler.return_value = mock_response_handler 69 | 70 | return MockClient( 71 | client=_mock_client, request_formatter=mock_request_formatter, response_handler=mock_response_handler 72 | ) 73 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import json as json_lib 2 | from io import BytesIO 3 | from unittest.mock import Mock 4 | 5 | import requests 6 | 7 | from apiclient import APIClient, JsonRequestFormatter, JsonResponseHandler, NoAuthentication 8 | from apiclient.request_formatters import BaseRequestFormatter 9 | from apiclient.request_strategies import BaseRequestStrategy 10 | from apiclient.response import RequestsResponse, Response 11 | from apiclient.response_handlers import BaseResponseHandler 12 | 13 | mock_response_handler_call = Mock() 14 | mock_request_formatter_call = Mock() 15 | mock_get_request_formatter_headers_call = Mock() 16 | 17 | 18 | class MinimalClient(APIClient): 19 | """Minimal client - no implementation.""" 20 | 21 | pass 22 | 23 | 24 | class MockResponseHandler(BaseResponseHandler): 25 | """Mock class for testing.""" 26 | 27 | @staticmethod 28 | def get_request_data(response): 29 | mock_response_handler_call(response) 30 | return response 31 | 32 | 33 | class MockRequestFormatter(BaseRequestFormatter): 34 | """Mock class for testing.""" 35 | 36 | @classmethod 37 | def get_headers(cls): 38 | mock_get_request_formatter_headers_call() 39 | return {} 40 | 41 | @classmethod 42 | def format(cls, data: dict): 43 | mock_request_formatter_call(data) 44 | return data 45 | 46 | 47 | class NoOpRequestStrategy(BaseRequestStrategy): 48 | """Request strategy to mock out all calls below client.""" 49 | 50 | def get(self, *args, **kwargs): 51 | pass 52 | 53 | def post(self, *args, **kwargs): 54 | pass 55 | 56 | def put(self, *args, **kwargs): 57 | pass 58 | 59 | def patch(self, *args, **kwargs): 60 | pass 61 | 62 | def delete(self, *args, **kwargs): 63 | pass 64 | 65 | 66 | def build_response(data=None, json=None, status_code: int = 200) -> Response: 67 | """Builds a requests.Response object with the data set as the content.""" 68 | response = requests.Response() 69 | response.status_code = status_code 70 | response.headers = { 71 | "Connection": "keep-alive", 72 | "Content-Encoding": "gzip", 73 | "Content-Type": "application/json; charset=utf-8", 74 | } 75 | response.encoding = "utf-8" 76 | if json: 77 | data = json_lib.dumps(json) 78 | response.raw = BytesIO(bytes(data, encoding="utf-8")) 79 | response.reason = "OK" 80 | response.url = "https://jsonplaceholder.typicode.com/todos" 81 | return RequestsResponse(response) 82 | 83 | 84 | def client_factory(build_with=None, request_strategy=None): 85 | """Return an initialized client class.""" 86 | factory_floor = { 87 | "json": MinimalClient( 88 | authentication_method=NoAuthentication(), 89 | response_handler=JsonResponseHandler, 90 | request_formatter=JsonRequestFormatter, 91 | ), 92 | "mocker": MinimalClient( 93 | authentication_method=NoAuthentication(), 94 | response_handler=MockResponseHandler, 95 | request_formatter=MockRequestFormatter, 96 | ), 97 | } 98 | client = factory_floor.get(build_with, factory_floor["mocker"]) 99 | if request_strategy is not None: 100 | client.set_request_strategy(request_strategy) 101 | return client 102 | -------------------------------------------------------------------------------- /tests/integration_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeWooster/api-client/380c28c9d28f05139e4d41aab1ea7d2729cbbbe1/tests/integration_tests/__init__.py -------------------------------------------------------------------------------- /tests/integration_tests/client.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum, unique 2 | from json import JSONDecodeError 3 | 4 | from apiclient import APIClient, endpoint, paginated, retry_request 5 | from apiclient.error_handlers import ErrorHandler 6 | from apiclient.exceptions import APIRequestError 7 | from apiclient.response import Response 8 | 9 | 10 | def by_query_params_callable(response, prev_params): 11 | if "nextPage" in response and response["nextPage"]: 12 | return {"page": response["nextPage"]} 13 | 14 | 15 | class InternalError(APIRequestError): 16 | message = "Internal error." 17 | 18 | 19 | class OtherError(APIRequestError): 20 | message = "Other error." 21 | 22 | 23 | @unique 24 | class ErrorCodes(IntEnum): 25 | INTERNAL_ERROR = 20001 26 | OTHER_ERROR = 20002 27 | 28 | 29 | ERROR_CODES_WITH_EXCEPTIONS_MAP = { 30 | ErrorCodes.INTERNAL_ERROR: InternalError, 31 | ErrorCodes.OTHER_ERROR: OtherError, 32 | } 33 | 34 | 35 | class ClientErrorHandler(ErrorHandler): 36 | @staticmethod 37 | def get_exception(response: Response) -> APIRequestError: 38 | if response.get_raw_data() == "": 39 | return ErrorHandler.get_exception(response) 40 | 41 | key_fields = ("errorCode", "error_code") 42 | error_code = None 43 | try: 44 | data = response.get_json() 45 | except JSONDecodeError: 46 | return ErrorHandler.get_exception(response) 47 | 48 | for key_field in key_fields: 49 | try: 50 | error_code = int(data.get(key_field)) 51 | if error_code is not None: 52 | break 53 | except (ValueError, TypeError): 54 | pass 55 | 56 | exception_class = ERROR_CODES_WITH_EXCEPTIONS_MAP.get(error_code) 57 | if not exception_class: 58 | return ErrorHandler.get_exception(response) 59 | 60 | return exception_class() 61 | 62 | 63 | @endpoint(base_url="http://testserver") 64 | class Urls: 65 | users = "users" 66 | user = "users/{id}" 67 | accounts = "accounts" 68 | 69 | 70 | class Client(APIClient): 71 | def get_request_timeout(self): 72 | return 0.1 73 | 74 | def list_users(self): 75 | return self.get(Urls.users) 76 | 77 | @retry_request 78 | def get_user(self, user_id: int): 79 | url = Urls.user.format(id=user_id) 80 | return self.get(url) 81 | 82 | def create_user(self, first_name, last_name): 83 | data = {"firstName": first_name, "lastName": last_name} 84 | return self.post(Urls.users, data=data) 85 | 86 | def overwrite_user(self, user_id, first_name, last_name): 87 | data = {"firstName": first_name, "lastName": last_name} 88 | url = Urls.user.format(id=user_id) 89 | return self.put(url, data=data) 90 | 91 | def update_user(self, user_id, first_name=None, last_name=None): 92 | data = {} 93 | if first_name: 94 | data["firstName"] = first_name 95 | if last_name: 96 | data["lastName"] = last_name 97 | url = Urls.user.format(id=user_id) 98 | return self.patch(url, data=data) 99 | 100 | def delete_user(self, user_id): 101 | url = Urls.user.format(id=user_id) 102 | return self.delete(url) 103 | 104 | @paginated(by_query_params=by_query_params_callable) 105 | def list_user_accounts_paginated(self, user_id): 106 | return self.get(Urls.accounts, params={"userId": user_id}) 107 | -------------------------------------------------------------------------------- /tests/integration_tests/test_client_integration.py: -------------------------------------------------------------------------------- 1 | # Integration tests using all request methods on a 2 | # real world api client with all methods implemented 3 | 4 | import pytest 5 | 6 | from apiclient import JsonRequestFormatter, JsonResponseHandler, NoAuthentication 7 | from apiclient.exceptions import ClientError, RedirectionError, ServerError, UnexpectedError 8 | from tests.integration_tests.client import Client, ClientErrorHandler, InternalError, OtherError, Urls 9 | 10 | 11 | def test_client_response(cassette): 12 | client = Client( 13 | authentication_method=NoAuthentication(), 14 | response_handler=JsonResponseHandler, 15 | request_formatter=JsonRequestFormatter, 16 | ) 17 | users = client.list_users() 18 | assert len(users) == 3 19 | assert users == [ 20 | {"userId": 1, "firstName": "Mike", "lastName": "Foo"}, 21 | {"userId": 2, "firstName": "Sarah", "lastName": "Bar"}, 22 | {"userId": 3, "firstName": "Barry", "lastName": "Baz"}, 23 | ] 24 | assert cassette.play_count == 1 25 | 26 | # User 1 requested successfully on first attempt 27 | user = client.get_user(user_id=1) 28 | assert user == {"userId": 1, "firstName": "Mike", "lastName": "Foo"} 29 | assert cassette.play_count == 2 30 | 31 | # User 2 failed on first attempt, succeeded on second 32 | user = client.get_user(user_id=2) 33 | assert user == {"userId": 2, "firstName": "Sarah", "lastName": "Bar"} 34 | assert cassette.play_count == 4 35 | 36 | new_user = client.create_user(first_name="Lucy", last_name="Qux") 37 | assert new_user == {"userId": 4, "firstName": "Lucy", "lastName": "Qux"} 38 | assert cassette.play_count == 5 39 | 40 | overwritten_user = client.overwrite_user(user_id=4, first_name="Lucy", last_name="Foo") 41 | assert overwritten_user == {"userId": 4, "firstName": "Lucy", "lastName": "Foo"} 42 | assert cassette.play_count == 6 43 | 44 | updated_user = client.update_user(user_id=4, first_name="Lucy", last_name="Qux") 45 | assert updated_user == {"userId": 4, "firstName": "Lucy", "lastName": "Qux"} 46 | assert cassette.play_count == 7 47 | 48 | # DELETE cassette doesn't seem to be working correctly. 49 | # deleted_user = client.delete_user(user_id=4) 50 | # assert deleted_user is None 51 | # assert cassette.play_count == 8 52 | 53 | pages = list(client.list_user_accounts_paginated(user_id=1)) 54 | assert len(pages) == 3 55 | assert pages == [ 56 | { 57 | "results": [ 58 | {"accountName": "business", "number": "1234"}, 59 | {"accountName": "expense", "number": "2345"}, 60 | ], 61 | "page": 1, 62 | "nextPage": 2, 63 | }, 64 | { 65 | "results": [ 66 | {"accountName": "fun", "number": "6544"}, 67 | {"accountName": "holiday", "number": "9283"}, 68 | ], 69 | "page": 2, 70 | "nextPage": 3, 71 | }, 72 | { 73 | "results": [ 74 | {"accountName": "gifts", "number": "7827"}, 75 | {"accountName": "home", "number": "1259"}, 76 | ], 77 | "page": 3, 78 | "nextPage": None, 79 | }, 80 | ] 81 | 82 | # Fails to connect when connecting to non-existent url. 83 | with pytest.raises(UnexpectedError) as exc_info: 84 | client.get("mock://testserver") 85 | assert str(exc_info.value) == "Error when contacting 'mock://testserver'" 86 | 87 | # User 10 failed on first attempt 500 with 20001 code 88 | client.set_error_handler(ClientErrorHandler) 89 | with pytest.raises(InternalError) as exc_info: 90 | client.list_user_accounts_paginated(user_id=10) 91 | assert str(exc_info.value) == "Internal error." 92 | # failed on second 400 with 20002 code 93 | with pytest.raises(OtherError) as exc_info: 94 | client.list_user_accounts_paginated(user_id=10) 95 | assert str(exc_info.value) == "Other error." 96 | # failed on third 500 with no_code 97 | with pytest.raises(ServerError) as exc_info: 98 | client.list_user_accounts_paginated(user_id=10) 99 | assert str(exc_info.value) == "500 Error: SERVER ERROR for url: http://testserver/accounts?userId=10" 100 | # and 504 with html body 101 | with pytest.raises(ServerError) as exc_info: 102 | client.list_user_accounts_paginated(user_id=10) 103 | assert str(exc_info.value) == "504 Error: SERVER ERROR for url: http://testserver/accounts?userId=10" 104 | # and 500 with empty body 105 | with pytest.raises(ServerError) as exc_info: 106 | client.list_user_accounts_paginated(user_id=10) 107 | assert str(exc_info.value) == "500 Error: SERVER ERROR for url: http://testserver/accounts?userId=10" 108 | 109 | 110 | @pytest.mark.parametrize( 111 | "user_id,expected_error,expected_message", 112 | [ 113 | (1, RedirectionError, "300 Error: REDIRECT for url: http://testserver/users/1"), 114 | (2, RedirectionError, "399 Error: REDIRECT for url: http://testserver/users/2"), 115 | (3, ClientError, "400 Error: CLIENT ERROR for url: http://testserver/users/3"), 116 | (4, ClientError, "499 Error: CLIENT ERROR for url: http://testserver/users/4"), 117 | (5, ServerError, "500 Error: SERVER ERROR for url: http://testserver/users/5"), 118 | (6, ServerError, "599 Error: SERVER ERROR for url: http://testserver/users/6"), 119 | (7, UnexpectedError, "600 Error: UNEXPECTED for url: http://testserver/users/7"), 120 | (8, UnexpectedError, "999 Error: UNEXPECTED for url: http://testserver/users/8"), 121 | ], 122 | ) 123 | def test_bad_response_error_codes(user_id, expected_error, expected_message, error_cassette): 124 | # Error cassette has been configured so that different users respond with different error codes 125 | 126 | client = Client( 127 | authentication_method=NoAuthentication(), 128 | response_handler=JsonResponseHandler, 129 | request_formatter=JsonRequestFormatter, 130 | ) 131 | 132 | url = Urls.user.format(id=user_id) 133 | 134 | # Test GET request 135 | with pytest.raises(expected_error) as exc_info: 136 | client.get(url) 137 | assert str(exc_info.value) == expected_message 138 | 139 | error_cassette.rewind() 140 | 141 | # Test POST request 142 | with pytest.raises(expected_error) as exc_info: 143 | client.post(url, data={"clientId": "1234"}) 144 | assert str(exc_info.value) == expected_message 145 | 146 | error_cassette.rewind() 147 | 148 | # Test PUT request 149 | with pytest.raises(expected_error) as exc_info: 150 | client.put(url, data={"clientId": "1234"}) 151 | assert str(exc_info.value) == expected_message 152 | 153 | error_cassette.rewind() 154 | 155 | # Test PATCH request 156 | with pytest.raises(expected_error) as exc_info: 157 | client.patch(url, data={"clientId": "1234"}) 158 | assert str(exc_info.value) == expected_message 159 | 160 | error_cassette.rewind() 161 | 162 | # Test DELETE request 163 | with pytest.raises(expected_error) as exc_info: 164 | client.delete(url) 165 | assert str(exc_info.value) == expected_message 166 | -------------------------------------------------------------------------------- /tests/test_authentication_methods.py: -------------------------------------------------------------------------------- 1 | import http.cookiejar 2 | 3 | import pytest 4 | 5 | from apiclient import ( 6 | APIClient, 7 | BasicAuthentication, 8 | HeaderAuthentication, 9 | NoAuthentication, 10 | QueryParameterAuthentication, 11 | ) 12 | from apiclient.authentication_methods import CookieAuthentication 13 | from apiclient.request_formatters import BaseRequestFormatter, NoOpRequestFormatter 14 | from apiclient.response_handlers import BaseResponseHandler, RequestsResponseHandler 15 | 16 | 17 | def test_no_authentication_method_does_not_alter_client(): 18 | client = APIClient( 19 | authentication_method=NoAuthentication(), 20 | response_handler=BaseResponseHandler, 21 | request_formatter=BaseRequestFormatter, 22 | ) 23 | assert client.get_default_query_params() == {} 24 | assert client.get_default_headers() == {} 25 | assert client.get_default_username_password_authentication() is None 26 | 27 | 28 | def test_query_parameter_authentication_alters_client_default_query_parameters(): 29 | client = APIClient( 30 | authentication_method=QueryParameterAuthentication(parameter="apikey", token="secret"), 31 | response_handler=BaseResponseHandler, 32 | request_formatter=BaseRequestFormatter, 33 | ) 34 | assert client.get_default_query_params() == {"apikey": "secret"} 35 | assert client.get_default_headers() == {} 36 | assert client.get_default_username_password_authentication() is None 37 | 38 | 39 | def test_header_authentication_with_default_values(): 40 | client = APIClient( 41 | authentication_method=HeaderAuthentication(token="secret"), 42 | response_handler=BaseResponseHandler, 43 | request_formatter=BaseRequestFormatter, 44 | ) 45 | assert client.get_default_query_params() == {} 46 | assert client.get_default_headers() == {"Authorization": "Bearer secret"} 47 | assert client.get_default_username_password_authentication() is None 48 | 49 | 50 | def test_header_authentication_overwriting_scheme(): 51 | client = APIClient( 52 | authentication_method=HeaderAuthentication(token="secret", scheme="Token"), 53 | response_handler=BaseResponseHandler, 54 | request_formatter=BaseRequestFormatter, 55 | ) 56 | assert client.get_default_query_params() == {} 57 | assert client.get_default_headers() == {"Authorization": "Token secret"} 58 | assert client.get_default_username_password_authentication() is None 59 | 60 | 61 | def test_header_authentication_overwriting_parameter(): 62 | client = APIClient( 63 | authentication_method=HeaderAuthentication(token="secret", parameter="APIKEY"), 64 | response_handler=BaseResponseHandler, 65 | request_formatter=BaseRequestFormatter, 66 | ) 67 | assert client.get_default_query_params() == {} 68 | assert client.get_default_headers() == {"APIKEY": "Bearer secret"} 69 | assert client.get_default_username_password_authentication() is None 70 | 71 | 72 | def test_header_authentication_with_extra_parameters(): 73 | client = APIClient( 74 | authentication_method=HeaderAuthentication( 75 | token="secret", parameter="APIKEY", extra={"another key": "another value"} 76 | ), 77 | response_handler=BaseResponseHandler, 78 | request_formatter=BaseRequestFormatter, 79 | ) 80 | assert client.get_default_query_params() == {} 81 | assert client.get_default_headers() == {"APIKEY": "Bearer secret", "another key": "another value"} 82 | assert client.get_default_username_password_authentication() is None 83 | 84 | 85 | @pytest.mark.parametrize("scheme", [None, "", 0]) 86 | def test_scheme_is_not_included_when_evaluates_to_false(scheme): 87 | client = APIClient( 88 | authentication_method=HeaderAuthentication(token="secret", parameter="APIKEY", scheme=scheme), 89 | response_handler=BaseResponseHandler, 90 | request_formatter=BaseRequestFormatter, 91 | ) 92 | assert client.get_default_query_params() == {} 93 | assert client.get_default_headers() == {"APIKEY": "secret"} 94 | assert client.get_default_username_password_authentication() is None 95 | 96 | 97 | def test_basic_authentication_alters_client(): 98 | client = APIClient( 99 | authentication_method=BasicAuthentication(username="uname", password="password"), 100 | response_handler=BaseResponseHandler, 101 | request_formatter=BaseRequestFormatter, 102 | ) 103 | assert client.get_default_query_params() == {} 104 | assert client.get_default_headers() == {} 105 | assert client.get_default_username_password_authentication() == ("uname", "password") 106 | 107 | 108 | def test_cookie_authentication_makes_request_on_client_initialization(mock_requests): 109 | cookiejar = http.cookiejar.CookieJar() 110 | mocker = mock_requests.get("http://example.com/authenticate", status_code=200, cookies=cookiejar) 111 | 112 | APIClient( 113 | authentication_method=CookieAuthentication( 114 | auth_url="http://example.com/authenticate", authentication=HeaderAuthentication(token="foo") 115 | ), 116 | response_handler=RequestsResponseHandler, 117 | request_formatter=NoOpRequestFormatter, 118 | ) 119 | assert mocker.called 120 | assert mocker.call_count == 1 121 | # TODO: is there a way we can test the cookiejar contents after making the request? 122 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, sentinel 2 | 3 | import pytest 4 | 5 | from apiclient import NoAuthentication 6 | from apiclient.authentication_methods import BaseAuthenticationMethod 7 | from apiclient.client import APIClient 8 | from apiclient.request_strategies import BaseRequestStrategy 9 | from tests.helpers import MinimalClient, MockRequestFormatter, MockResponseHandler, client_factory 10 | 11 | 12 | def test_client_initialization_with_invalid_authentication_method(): 13 | with pytest.raises(RuntimeError) as exc_info: 14 | MinimalClient( 15 | authentication_method=object(), 16 | response_handler=MockResponseHandler, 17 | request_formatter=MockRequestFormatter, 18 | ) 19 | expected_message = "provided authentication_method must be an instance of BaseAuthenticationMethod." 20 | assert str(exc_info.value) == expected_message 21 | 22 | 23 | def test_client_initialization_with_invalid_response_handler(): 24 | with pytest.raises(RuntimeError) as exc_info: 25 | MinimalClient( 26 | authentication_method=NoAuthentication(), 27 | response_handler=None, 28 | request_formatter=MockRequestFormatter, 29 | ) 30 | assert str(exc_info.value) == "provided response_handler must be a subclass of BaseResponseHandler." 31 | 32 | 33 | def test_client_initialization_with_invalid_exception_handler(): 34 | with pytest.raises(RuntimeError) as exc_info: 35 | MinimalClient( 36 | authentication_method=NoAuthentication(), 37 | error_handler=None, 38 | request_formatter=MockRequestFormatter, 39 | ) 40 | assert str(exc_info.value) == "provided error_handler must be a subclass of BaseErrorHandler." 41 | 42 | 43 | def test_client_initialization_with_invalid_requests_handler(): 44 | with pytest.raises(RuntimeError) as exc_info: 45 | MinimalClient( 46 | authentication_method=NoAuthentication(), 47 | response_handler=MockResponseHandler, 48 | request_formatter=None, 49 | ) 50 | assert str(exc_info.value) == "provided request_formatter must be a subclass of BaseRequestFormatter." 51 | 52 | 53 | def test_client_initialization_with_invalid_request_strategy(): 54 | with pytest.raises(RuntimeError) as exc_info: 55 | MinimalClient( 56 | authentication_method=NoAuthentication(), 57 | response_handler=MockResponseHandler, 58 | request_formatter=MockRequestFormatter, 59 | request_strategy=object(), 60 | ) 61 | assert str(exc_info.value) == "provided request_strategy must be an instance of BaseRequestStrategy." 62 | 63 | 64 | def test_get_method_delegates_to_request_strategy(): 65 | mock_request_strategy = Mock(spec=BaseRequestStrategy) 66 | mock_request_strategy.get.return_value = sentinel.response 67 | client = client_factory() 68 | client.set_request_strategy(mock_request_strategy) 69 | 70 | response = client.get(sentinel.url, params=sentinel.params, headers=sentinel.headers) 71 | 72 | mock_request_strategy.get.assert_called_once_with( 73 | sentinel.url, params=sentinel.params, headers=sentinel.headers 74 | ) 75 | assert response == sentinel.response 76 | 77 | 78 | def test_post_method_delegates_to_request_strategy(): 79 | mock_request_strategy = Mock(spec=BaseRequestStrategy) 80 | mock_request_strategy.post.return_value = sentinel.response 81 | client = client_factory() 82 | client.set_request_strategy(mock_request_strategy) 83 | 84 | response = client.post( 85 | sentinel.url, data=sentinel.data, params=sentinel.params, headers=sentinel.headers 86 | ) 87 | 88 | mock_request_strategy.post.assert_called_once_with( 89 | sentinel.url, data=sentinel.data, params=sentinel.params, headers=sentinel.headers 90 | ) 91 | assert response == sentinel.response 92 | 93 | 94 | def test_put_method_delegates_to_request_strategy(): 95 | mock_request_strategy = Mock(spec=BaseRequestStrategy) 96 | mock_request_strategy.put.return_value = sentinel.response 97 | client = client_factory() 98 | client.set_request_strategy(mock_request_strategy) 99 | 100 | response = client.put(sentinel.url, data=sentinel.data, params=sentinel.params, headers=sentinel.headers) 101 | 102 | mock_request_strategy.put.assert_called_once_with( 103 | sentinel.url, data=sentinel.data, params=sentinel.params, headers=sentinel.headers 104 | ) 105 | assert response == sentinel.response 106 | 107 | 108 | def test_patch_method_delegates_to_request_strategy(): 109 | mock_request_strategy = Mock(spec=BaseRequestStrategy) 110 | mock_request_strategy.patch.return_value = sentinel.response 111 | client = client_factory() 112 | client.set_request_strategy(mock_request_strategy) 113 | 114 | response = client.patch( 115 | sentinel.url, data=sentinel.data, params=sentinel.params, headers=sentinel.headers 116 | ) 117 | 118 | mock_request_strategy.patch.assert_called_once_with( 119 | sentinel.url, data=sentinel.data, params=sentinel.params, headers=sentinel.headers 120 | ) 121 | assert response == sentinel.response 122 | 123 | 124 | def test_delete_method_delegates_to_request_strategy(): 125 | mock_request_strategy = Mock(spec=BaseRequestStrategy) 126 | mock_request_strategy.delete.return_value = sentinel.response 127 | client = client_factory() 128 | client.set_request_strategy(mock_request_strategy) 129 | 130 | response = client.delete(sentinel.url, params=sentinel.params, headers=sentinel.headers) 131 | 132 | mock_request_strategy.delete.assert_called_once_with( 133 | sentinel.url, params=sentinel.params, headers=sentinel.headers 134 | ) 135 | assert response == sentinel.response 136 | 137 | 138 | def test_setting_incorrect_request_strategy_raises_runtime_error(): 139 | client = client_factory() 140 | with pytest.raises(RuntimeError) as exc_info: 141 | client.set_request_strategy("not a strategy") 142 | assert str(exc_info.value) == "provided request_strategy must be an instance of BaseRequestStrategy." 143 | 144 | 145 | def test_client_get_and_set_session(): 146 | client = APIClient() 147 | client.set_session(sentinel.session) 148 | assert client.get_session() == sentinel.session 149 | 150 | 151 | def test_client_clone_method(): 152 | client = client_factory(build_with="json") 153 | client.set_session(sentinel.session) 154 | new_client = client.clone() 155 | assert new_client.get_session() is client.get_session() 156 | 157 | 158 | def test_get_authentication_method_with_user_defined(): 159 | custom_authentication_method = BaseAuthenticationMethod() 160 | client = MinimalClient(authentication_method=custom_authentication_method) 161 | assert client.get_authentication_method() is custom_authentication_method 162 | 163 | 164 | def test_get_request_strategy_with_user_defined(): 165 | custom_request_strategy = BaseRequestStrategy() 166 | client = MinimalClient(request_strategy=custom_request_strategy) 167 | assert client.get_request_strategy() is custom_request_strategy 168 | -------------------------------------------------------------------------------- /tests/test_endpoints.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from apiclient import endpoint 6 | 7 | 8 | @endpoint(base_url="http://foo.com") 9 | class Endpoint: 10 | search = "search" 11 | integer = 3 12 | search_id = "search/{id}" 13 | _protected = "protected" 14 | 15 | 16 | @endpoint(base_url="http://foo.com///") 17 | class EndpointWithExtraSlash: 18 | search = "///search" 19 | 20 | 21 | class EndpointNotDecorated: 22 | search = "search" 23 | 24 | 25 | @endpoint(base_url=os.environ["ENDPOINT_BASE_URL"]) 26 | class EndpointFromEnvironment: 27 | search = "search" 28 | 29 | 30 | class BaseEndpoint: 31 | get_apples = "apples" 32 | get_grapes = "grapes" 33 | 34 | def method(self): 35 | pass 36 | 37 | 38 | @endpoint(base_url="https://fruits.com") 39 | class SubEndpoint(BaseEndpoint): 40 | get_hamburgers = "hamburgers" 41 | _ignore_attr = "ignored" 42 | 43 | 44 | def test_endpoint(): 45 | assert Endpoint.search == "http://foo.com/search" 46 | assert Endpoint.integer == "http://foo.com/3" 47 | 48 | 49 | def test_decorator_removes_trailing_slashes_from_base_url(): 50 | assert EndpointWithExtraSlash.search == "http://foo.com/search" 51 | 52 | 53 | def test_endpoint_must_contain_base_url(): 54 | with pytest.raises(RuntimeError) as exc_info: 55 | endpoint(EndpointNotDecorated) 56 | expected_message = "A decorated endpoint must define a base_url as @endpoint(base_url='http://foo.com')." 57 | assert str(exc_info.value) == expected_message 58 | 59 | 60 | def test_endpoint_with_formatting(): 61 | assert Endpoint.search_id == "http://foo.com/search/{id}" 62 | assert Endpoint.search_id.format(id=34) == "http://foo.com/search/34" 63 | 64 | 65 | def test_decorator_does_not_modify_protected_attributes(): 66 | assert Endpoint._protected == "protected" 67 | 68 | 69 | def test_decorated_endpoint_loaded_from_environment_variable(): 70 | assert EndpointFromEnvironment.search == "http://environment.com/search" 71 | 72 | 73 | def test_decorator_inherits_attributes(): 74 | assert BaseEndpoint.get_apples == "apples" 75 | assert BaseEndpoint.get_grapes == "grapes" 76 | assert SubEndpoint.get_apples == "https://fruits.com/apples" 77 | assert SubEndpoint.get_grapes == "https://fruits.com/grapes" 78 | assert SubEndpoint.get_hamburgers == "https://fruits.com/hamburgers" 79 | assert SubEndpoint._ignore_attr == "ignored" 80 | -------------------------------------------------------------------------------- /tests/test_error_handlers.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import sentinel 2 | 3 | import pytest 4 | 5 | from apiclient.error_handlers import BaseErrorHandler 6 | from apiclient.response import RequestsResponse 7 | 8 | 9 | class TestBaseExceptionHandler: 10 | handler = BaseErrorHandler 11 | 12 | def test_get_exception_needs_implementation(self): 13 | with pytest.raises(NotImplementedError): 14 | self.handler.get_exception(RequestsResponse(sentinel.response)) 15 | -------------------------------------------------------------------------------- /tests/test_paginators.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | 5 | from apiclient import APIClient, JsonRequestFormatter, JsonResponseHandler, paginated 6 | from apiclient.authentication_methods import NoAuthentication 7 | from apiclient.paginators import set_strategy 8 | from apiclient.request_strategies import BaseRequestStrategy, RequestStrategy 9 | from tests.helpers import client_factory 10 | 11 | 12 | def next_page_param(response, previous_page_params): 13 | if response["next"]: 14 | return {"page": response["next"]} 15 | 16 | 17 | def next_page_url(response, previous_page_url): 18 | if response["next"]: 19 | return response["next"] 20 | 21 | 22 | class QueryPaginatedClient(APIClient): 23 | def __init__(self, base_url, **kwargs): 24 | self.base_url = base_url 25 | super().__init__(**kwargs) 26 | 27 | @paginated(by_query_params=next_page_param) 28 | def make_read_request(self): 29 | return self.get(endpoint=self.base_url) 30 | 31 | 32 | class UrlPaginatedClient(APIClient): 33 | def __init__(self, base_url, **kwargs): 34 | self.base_url = base_url 35 | super().__init__(**kwargs) 36 | 37 | @paginated(by_url=next_page_url) 38 | def make_read_request(self): 39 | return self.get(endpoint=self.base_url) 40 | 41 | 42 | def test_query_parameter_pagination(mock_requests): 43 | # Given the response is over three pages 44 | response_data = [ 45 | {"page1": "data", "next": "page2"}, 46 | {"page2": "data", "next": "page3"}, 47 | {"page3": "data", "next": None}, 48 | ] 49 | mock_requests.get( 50 | "mock://testserver.com", 51 | [ 52 | {"json": {"page1": "data", "next": "page2"}, "status_code": 200}, 53 | {"json": {"page2": "data", "next": "page3"}, "status_code": 200}, 54 | {"json": {"page3": "data", "next": None}, "status_code": 200}, 55 | ], 56 | ) 57 | # mock_requests.get.side_effect = [build_response(json=page_data) for page_data in response_data] 58 | client = QueryPaginatedClient( 59 | base_url="mock://testserver.com", 60 | authentication_method=NoAuthentication(), 61 | response_handler=JsonResponseHandler, 62 | request_formatter=JsonRequestFormatter, 63 | ) 64 | # And the client has been set up with the SinglePagePaginator 65 | original_strategy = client.get_request_strategy() 66 | assert isinstance(original_strategy, RequestStrategy) 67 | 68 | # When I call the client method 69 | response = list(client.make_read_request()) 70 | 71 | # Then two requests are made to get both pages 72 | assert mock_requests.call_count == 3 73 | assert len(response) == 3 74 | assert response == response_data 75 | 76 | # And the clients paginator is reset back to the original. 77 | assert client.get_request_strategy() == original_strategy 78 | 79 | 80 | def test_url_parameter_pagination(mock_requests): 81 | # Given the response is over two pages 82 | mock_requests.get( 83 | "mock://testserver.com", 84 | json={"page1": "data", "next": "mock://testserver.com/page2"}, 85 | status_code=200, 86 | ) 87 | mock_requests.get("mock://testserver.com/page2", json={"page2": "data", "next": None}, status_code=200) 88 | response_data = [ 89 | {"page1": "data", "next": "mock://testserver.com/page2"}, 90 | {"page2": "data", "next": None}, 91 | ] 92 | client = UrlPaginatedClient( 93 | base_url="mock://testserver.com", 94 | authentication_method=NoAuthentication(), 95 | response_handler=JsonResponseHandler, 96 | request_formatter=JsonRequestFormatter, 97 | ) 98 | # And the client has been set up with the SinglePagePaginator 99 | original_strategy = client.get_request_strategy() 100 | assert isinstance(original_strategy, RequestStrategy) 101 | 102 | # When I call the client method 103 | response = list(client.make_read_request()) 104 | 105 | # Then two requests are made to get both pages 106 | assert mock_requests.call_count == 2 107 | assert response == response_data 108 | 109 | # And the clients paginator is reset back to the original. 110 | assert client.get_request_strategy() == original_strategy 111 | 112 | 113 | def test_set_strategy_changes_strategy_on_copy_of_client_when_in_context(): 114 | client = client_factory() 115 | original_strategy = client.get_request_strategy() 116 | new_strategy = Mock(spec=BaseRequestStrategy) 117 | 118 | with set_strategy(client, new_strategy) as temporary_client: 119 | assert client.get_request_strategy() == original_strategy 120 | assert temporary_client.get_request_strategy() == new_strategy 121 | 122 | assert client.get_request_strategy() == original_strategy 123 | 124 | 125 | def test_context_manager_resets_request_strategy_when_error(): 126 | client = client_factory() 127 | original_strategy = client.get_request_strategy() 128 | new_strategy = Mock(spec=BaseRequestStrategy) 129 | raises_when_called = Mock(side_effect=ValueError("Something went wrong")) 130 | 131 | with pytest.raises(ValueError): 132 | with set_strategy(client, new_strategy): 133 | raises_when_called() 134 | 135 | assert client.get_request_strategy() == original_strategy 136 | -------------------------------------------------------------------------------- /tests/test_request_formatters.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import sentinel 2 | 3 | import pytest 4 | 5 | from apiclient import JsonRequestFormatter 6 | from apiclient.request_formatters import BaseRequestFormatter, NoOpRequestFormatter 7 | 8 | 9 | class RequestFormatter(BaseRequestFormatter): 10 | content_type = "xml" 11 | 12 | 13 | def test_format_method_needs_implementation(): 14 | with pytest.raises(NotImplementedError): 15 | BaseRequestFormatter.format({"foo": "bar"}) 16 | 17 | 18 | def test_json_formatter_formats_dictionary_to_json(): 19 | data = {"foo": "bar"} 20 | assert JsonRequestFormatter.format(data) == '{"foo": "bar"}' 21 | 22 | 23 | def test_json_formatter_takes_no_action_when_passed_none_type(): 24 | data = None 25 | assert JsonRequestFormatter.format(data) is None 26 | 27 | 28 | def test_no_op_formatter_proxies_input(): 29 | assert NoOpRequestFormatter.format(sentinel.data) == sentinel.data 30 | -------------------------------------------------------------------------------- /tests/test_request_strategies.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, call, sentinel 2 | 3 | import pytest 4 | 5 | from apiclient import APIClient 6 | from apiclient.request_strategies import ( 7 | BaseRequestStrategy, 8 | QueryParamPaginatedRequestStrategy, 9 | RequestStrategy, 10 | UrlPaginatedRequestStrategy, 11 | ) 12 | from tests.helpers import client_factory 13 | 14 | 15 | def request_strategy_factory(strategy_class, build_client_with="json", **kwargs): 16 | """Helper method to build a strategy with a client.""" 17 | client = client_factory(build_with=build_client_with) 18 | strategy = strategy_class(**kwargs) 19 | strategy.set_client(client) 20 | return strategy 21 | 22 | 23 | def test_setting_and_getting_client(): 24 | strategy = BaseRequestStrategy() 25 | strategy.set_client(sentinel.client) 26 | assert strategy.get_client() == sentinel.client 27 | 28 | 29 | def assert_request_called_once(mock_requests, expected_url, expected_method): 30 | assert mock_requests.call_count == 1 31 | history = mock_requests.request_history 32 | assert history[0].url == expected_url 33 | assert history[0].method == expected_method 34 | 35 | 36 | def assert_mock_client_called_once(mock_client, expected_request_data): 37 | assert mock_client.client.get_default_query_params.call_count == 1 38 | assert mock_client.client.get_default_headers.call_count == 1 39 | assert mock_client.client.get_default_username_password_authentication.call_count == 1 40 | assert mock_client.request_formatter.format.call_count == 1 41 | assert mock_client.request_formatter.format.call_args == call(expected_request_data) 42 | assert mock_client.client.get_request_timeout.call_count == 1 43 | 44 | 45 | def test_request_strategy_sets_session_on_parent_when_not_already_set(mock_client): 46 | mock_client.client.get_session.return_value = None 47 | strategy = RequestStrategy() 48 | strategy.set_client(mock_client.client) 49 | mock_client.client.get_session.assert_called_once_with() 50 | mock_client.client.set_session.assert_called_once() 51 | 52 | 53 | def test_request_strategy_does_not_set_session_if_already_set(mock_client): 54 | mock_client.client.get_session.return_value = sentinel.session 55 | strategy = RequestStrategy() 56 | strategy.set_client(mock_client.client) 57 | mock_client.client.get_session.assert_called_once_with() 58 | mock_client.client.set_session.assert_not_called() 59 | 60 | 61 | def test_request_strategy_get_method_delegates_to_parent_handlers(mock_requests, mock_client): 62 | mock_requests.get("mock://testserver.com", json={"active": True}, status_code=200) 63 | 64 | strategy = RequestStrategy() 65 | strategy.set_client(mock_client.client) 66 | 67 | response = strategy.get("mock://testserver.com", params={"foo": sentinel.params}) 68 | 69 | assert response == sentinel.result 70 | assert_request_called_once(mock_requests, "mock://testserver.com", "GET") 71 | assert_mock_client_called_once(mock_client, None) 72 | 73 | 74 | def test_request_strategy_post_method_delegates_to_parent_handlers(mock_requests, mock_client): 75 | mock_requests.post("mock://testserver.com", json={"active": True}, status_code=200) 76 | 77 | strategy = RequestStrategy() 78 | strategy.set_client(mock_client.client) 79 | 80 | response = strategy.post( 81 | "mock://testserver.com", data={"data": sentinel.data}, params={"foo": sentinel.params} 82 | ) 83 | 84 | assert response == sentinel.result 85 | assert_request_called_once(mock_requests, "mock://testserver.com", "POST") 86 | assert_mock_client_called_once(mock_client, {"data": sentinel.data}) 87 | 88 | 89 | def test_request_strategy_put_method_delegates_to_parent_handlers(mock_requests, mock_client): 90 | mock_requests.put("mock://testserver.com", json={"active": True}, status_code=200) 91 | 92 | strategy = RequestStrategy() 93 | strategy.set_client(mock_client.client) 94 | 95 | response = strategy.put( 96 | "mock://testserver.com", data={"data": sentinel.data}, params={"foo": sentinel.params} 97 | ) 98 | 99 | assert response == sentinel.result 100 | assert_request_called_once(mock_requests, "mock://testserver.com", "PUT") 101 | assert_mock_client_called_once(mock_client, {"data": sentinel.data}) 102 | 103 | 104 | def test_request_strategy_patch_method_delegates_to_parent_handlers(mock_requests, mock_client): 105 | mock_requests.patch("mock://testserver.com", json={"active": True}, status_code=200) 106 | 107 | strategy = RequestStrategy() 108 | strategy.set_client(mock_client.client) 109 | 110 | response = strategy.patch( 111 | "mock://testserver.com", data={"data": sentinel.data}, params={"foo": sentinel.params} 112 | ) 113 | 114 | assert response == sentinel.result 115 | assert_request_called_once(mock_requests, "mock://testserver.com", "PATCH") 116 | assert_mock_client_called_once(mock_client, {"data": sentinel.data}) 117 | 118 | 119 | def test_request_strategy_delete_method_delegates_to_parent_handlers(mock_requests, mock_client): 120 | mock_requests.delete("mock://testserver.com", json={"active": True}, status_code=200) 121 | 122 | strategy = RequestStrategy() 123 | strategy.set_client(mock_client.client) 124 | 125 | response = strategy.delete("mock://testserver.com", params={"foo": sentinel.params}) 126 | 127 | assert response == sentinel.result 128 | assert_request_called_once(mock_requests, "mock://testserver.com", "DELETE") 129 | assert_mock_client_called_once(mock_client, None) 130 | 131 | 132 | @pytest.mark.parametrize("initial_params", [{"my-param": "always-set"}, None]) 133 | def test_query_param_paginated_strategy_delegates_to_callable(initial_params, mock_requests): 134 | # Given our next page callable will return: 135 | # - a page 2 param 136 | # - a None value - indicating it is the final page 137 | mock_requests.get( 138 | "mock://testserver.com", 139 | [ 140 | {"json": {"data": ["element1", "element2"], "nextPage": "2"}, "status_code": 200}, 141 | {"json": {"data": ["element3", "element4"], "nextPage": None}, "status_code": 200}, 142 | ], 143 | ) 144 | 145 | def next_page_callback(response, previous_params): 146 | return {"nextPage": response["nextPage"]} if response["nextPage"] else None 147 | 148 | strategy = request_strategy_factory(QueryParamPaginatedRequestStrategy, next_page=next_page_callback) 149 | 150 | # When we request the page 151 | response = strategy.get("mock://testserver.com", params=initial_params) 152 | 153 | # Then the first page is fetched and the paginator stops 154 | assert list(response) == [ 155 | {"data": ["element1", "element2"], "nextPage": "2"}, 156 | {"data": ["element3", "element4"], "nextPage": None}, 157 | ] 158 | assert mock_requests.called 159 | assert mock_requests.call_count == 2 160 | history = mock_requests.request_history 161 | assert history[0].url == "mock://testserver.com" 162 | assert history[1].url == "mock://testserver.com" 163 | 164 | 165 | def test_url_paginated_strategy_delegates_to_callable(mock_requests): 166 | # Given our next page callable will return: 167 | # - first, a new url for the request to go to. 168 | # - second, a none value telling the paginator to stop. 169 | mock_requests.get( 170 | "mock://testserver.com", 171 | json={"data": ["element1", "element2"], "nextPage": "mock://testserver.com/2"}, 172 | status_code=200, 173 | ) 174 | mock_requests.get( 175 | "mock://testserver.com/2", json={"data": ["element3", "element4"], "nextPage": None}, status_code=200 176 | ) 177 | 178 | def next_page_callback(response, previous_params): 179 | return response["nextPage"] 180 | 181 | strategy = request_strategy_factory(UrlPaginatedRequestStrategy, next_page=next_page_callback) 182 | 183 | # When we request the page 184 | response = strategy.get("mock://testserver.com") 185 | 186 | # Then the first page is fetched and the paginator stops 187 | assert list(response) == [ 188 | {"data": ["element1", "element2"], "nextPage": "mock://testserver.com/2"}, 189 | {"data": ["element3", "element4"], "nextPage": None}, 190 | ] 191 | assert mock_requests.called 192 | assert mock_requests.call_count == 2 193 | # And the paginator is called with the latest response and the original params 194 | history = mock_requests.request_history 195 | assert history[0].url == "mock://testserver.com" 196 | assert history[1].url == "mock://testserver.com/2" 197 | 198 | 199 | def test_mock_strategy(): 200 | mock_strategy = Mock(spec=BaseRequestStrategy) 201 | client = APIClient(request_strategy=mock_strategy) 202 | client.get("http://google.com") 203 | mock_strategy.get.assert_called_with("http://google.com", params=None) 204 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | 5 | from apiclient.response import RequestsResponse, Response 6 | 7 | 8 | class TestResponse: 9 | """Simple tests for 100% coverage - testing abstract class.""" 10 | 11 | response = Response() 12 | 13 | @pytest.mark.parametrize( 14 | "method", 15 | [ 16 | response.get_original, 17 | response.get_status_code, 18 | response.get_raw_data, 19 | response.get_json, 20 | response.get_status_reason, 21 | response.get_requested_url, 22 | ], 23 | ) 24 | def test_needs_implementation(self, method): 25 | with pytest.raises(NotImplementedError): 26 | method() 27 | 28 | 29 | class TestRequestsResponse: 30 | def test_get_status_reason_returns_empty_string_when_none(self): 31 | requests_response = Mock(reason=None) 32 | response = RequestsResponse(requests_response) 33 | assert response.get_status_reason() == "" 34 | -------------------------------------------------------------------------------- /tests/test_response_handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import sentinel 3 | from xml.etree import ElementTree 4 | 5 | import pytest 6 | 7 | from apiclient import JsonResponseHandler, RequestsResponseHandler, XmlResponseHandler 8 | from apiclient.exceptions import ResponseParseError 9 | from apiclient.response import RequestsResponse 10 | from apiclient.response_handlers import BaseResponseHandler 11 | from tests.helpers import build_response 12 | 13 | 14 | @pytest.fixture 15 | def blank_response(): 16 | """Fixture that constructs a response with a blank body.""" 17 | return build_response(data="") 18 | 19 | 20 | class TestBaseResponseHandler: 21 | handler = BaseResponseHandler 22 | 23 | def test_get_request_data_needs_implementation(self): 24 | with pytest.raises(NotImplementedError): 25 | self.handler.get_request_data(sentinel.response) 26 | 27 | 28 | class TestRequestsResponseHandler: 29 | handler = RequestsResponseHandler 30 | 31 | def test_original_response_is_returned(self): 32 | data = self.handler.get_request_data(RequestsResponse(sentinel.response)) 33 | assert data == sentinel.response 34 | 35 | 36 | class TestJsonResponseHandler: 37 | handler = JsonResponseHandler 38 | 39 | def test_response_json_is_parsed_correctly(self): 40 | response = build_response(data=json.dumps({"foo": "bar"})) 41 | data = self.handler.get_request_data(response) 42 | assert data == {"foo": "bar"} 43 | 44 | def test_bad_json_raises_response_parse_error(self): 45 | response = build_response(data="foo") 46 | with pytest.raises(ResponseParseError) as exc_info: 47 | self.handler.get_request_data(response) 48 | assert str(exc_info.value) == "Unable to decode response data to json. data='foo'" 49 | 50 | def test_blank_response_body_returns_none(self, blank_response): 51 | data = self.handler.get_request_data(blank_response) 52 | assert data is None 53 | 54 | 55 | class TestXmlResponseHandler: 56 | handler = XmlResponseHandler 57 | 58 | def test_response_data_is_parsed_correctly(self): 59 | response = build_response(data='Test Title') 60 | data = self.handler.get_request_data(response) 61 | assert isinstance(data, ElementTree.Element) 62 | assert data.tag == "xml" 63 | assert data[0].tag == "title" 64 | assert data[0].text == "Test Title" 65 | 66 | def test_bad_xml_raises_response_parse_error(self): 67 | response = build_response(data="foo") 68 | with pytest.raises(ResponseParseError) as exc_info: 69 | self.handler.get_request_data(response) 70 | assert str(exc_info.value) == "Unable to parse response data to xml. data='foo'" 71 | 72 | def test_blank_response_body_returns_none(self, blank_response): 73 | data = self.handler.get_request_data(blank_response) 74 | assert data is None 75 | -------------------------------------------------------------------------------- /tests/test_retrying.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from fractions import Fraction 3 | from unittest.mock import sentinel 4 | 5 | import pytest 6 | import six 7 | import tenacity 8 | 9 | from apiclient import retry_request 10 | from apiclient.exceptions import APIRequestError, ClientError, RedirectionError, ServerError, UnexpectedError 11 | from apiclient.retrying import retry_if_api_request_error 12 | 13 | 14 | # Testing utils - extracted directly from tenacity testing module: 15 | def _set_delay_since_start(retry_state, delay): 16 | # Ensure outcome_timestamp - start_time is *exactly* equal to the delay to 17 | # avoid complexity in test code. 18 | retry_state.start_time = Fraction(retry_state.start_time) 19 | retry_state.outcome_timestamp = retry_state.start_time + Fraction(delay) 20 | assert retry_state.seconds_since_start == delay 21 | 22 | 23 | _unset = object() 24 | 25 | 26 | def _make_unset_exception(func_name, **kwargs): 27 | missing = [] 28 | for k, v in six.iteritems(kwargs): 29 | if v is _unset: 30 | missing.append(k) 31 | missing_str = ", ".join(repr(s) for s in missing) 32 | return TypeError(func_name + " func missing parameters: " + missing_str) 33 | 34 | 35 | def make_retry_state(previous_attempt_number, delay_since_first_attempt, last_result=None): 36 | """Construct RetryCallState for given attempt number & delay. 37 | 38 | Only used in testing and thus is extra careful about timestamp arithmetics. 39 | """ 40 | required_parameter_unset = previous_attempt_number is _unset or delay_since_first_attempt is _unset 41 | if required_parameter_unset: 42 | raise _make_unset_exception( 43 | "wait/stop", 44 | previous_attempt_number=previous_attempt_number, 45 | delay_since_first_attempt=delay_since_first_attempt, 46 | ) 47 | 48 | from tenacity import RetryCallState 49 | 50 | retry_state = RetryCallState(None, None, (), {}) 51 | retry_state.attempt_number = previous_attempt_number 52 | if last_result is not None: 53 | retry_state.outcome = last_result 54 | else: 55 | retry_state.set_result(None) 56 | _set_delay_since_start(retry_state, delay_since_first_attempt) 57 | return retry_state 58 | 59 | 60 | class RunnableCounter: 61 | def __init__(self, side_effects): 62 | if not isinstance(side_effects, (list, tuple)): 63 | # Need to access side effects by index location. 64 | self.side_effects = [side_effects] 65 | else: 66 | self.side_effects = side_effects 67 | self.call_count = 0 68 | 69 | def __call__(self): 70 | try: 71 | side_effect = self.side_effects[self.call_count] 72 | except IndexError: 73 | # Re-use the last element in the list 74 | side_effect = self.side_effects[-1] 75 | self.call_count += 1 76 | if isinstance(side_effect, Exception): 77 | raise side_effect 78 | return side_effect 79 | 80 | 81 | @contextmanager 82 | def testing_retries(max_attempts=None, wait=None): 83 | """Context manager to create a retry decorated function. 84 | 85 | If provided, wait and stop will be overridden for testing purposes. 86 | """ 87 | 88 | @retry_request 89 | def _retry_enabled_function(callable=None): 90 | if callable: 91 | return callable() 92 | 93 | if max_attempts is not None: 94 | _retry_enabled_function.retry.stop = tenacity.stop_after_attempt(max_attempts) 95 | if wait is not None: 96 | _retry_enabled_function.retry.wait = wait 97 | 98 | yield _retry_enabled_function 99 | 100 | 101 | @contextmanager 102 | def testing_retry_for_status_code(status_codes=None): 103 | """Context manager to create a retry decorated function to test status codes.""" 104 | 105 | @tenacity.retry( 106 | retry=retry_if_api_request_error(status_codes=status_codes), 107 | wait=tenacity.wait_fixed(0), 108 | stop=tenacity.stop_after_attempt(2), 109 | reraise=True, 110 | ) 111 | def _retry_enabled_function(callable=None): 112 | if callable: 113 | return callable() 114 | 115 | yield _retry_enabled_function 116 | 117 | 118 | @pytest.mark.parametrize( 119 | "retry_state,max_wait", 120 | [ 121 | (make_retry_state(previous_attempt_number=1, delay_since_first_attempt=0), 0.25), 122 | (make_retry_state(previous_attempt_number=2, delay_since_first_attempt=0), 0.5), 123 | (make_retry_state(previous_attempt_number=3, delay_since_first_attempt=0), 1), 124 | (make_retry_state(previous_attempt_number=4, delay_since_first_attempt=0), 2), 125 | (make_retry_state(previous_attempt_number=5, delay_since_first_attempt=0), 4), 126 | (make_retry_state(previous_attempt_number=6, delay_since_first_attempt=0), 8), 127 | (make_retry_state(previous_attempt_number=7, delay_since_first_attempt=0), 16), 128 | (make_retry_state(previous_attempt_number=8, delay_since_first_attempt=0), 30), 129 | ], 130 | ) 131 | def test_exponential_retry_backoff(retry_state, max_wait): 132 | # Expecting exponential backoff with a max delay of 30s and min delay of 0.25s 133 | min_wait = max_wait * 0.75 134 | with testing_retries() as func: 135 | assert min_wait <= func.retry.wait(retry_state) <= max_wait 136 | 137 | 138 | @pytest.mark.parametrize("previous_attempt_number", [8, 9, 30, 1_000_000]) 139 | def test_exponential_retry_backoff_not_greater_than_30s(previous_attempt_number): 140 | retry_state = make_retry_state( 141 | previous_attempt_number=previous_attempt_number, delay_since_first_attempt=0 142 | ) 143 | with testing_retries() as func: 144 | # Expecting the wait to be somewhere between 22.5 (30*.75) and 30 145 | assert 22.5 <= func.retry.wait(retry_state) <= 30 146 | 147 | 148 | @pytest.mark.parametrize( 149 | "delay_since_first_attempt,stop", 150 | [(0.1, False), (299.9, False), (300.0, True), (300.1, True), (301.0, True)], 151 | ) 152 | def test_maximum_attempt_time_exceeded(delay_since_first_attempt, stop): 153 | retry_state = make_retry_state( 154 | previous_attempt_number=0, delay_since_first_attempt=delay_since_first_attempt 155 | ) 156 | with testing_retries() as func: 157 | assert func.retry.stop(retry_state) is stop 158 | 159 | 160 | @pytest.mark.parametrize( 161 | "exception_class", [APIRequestError, RedirectionError, ClientError, ServerError, UnexpectedError] 162 | ) 163 | def test_reraises_if_always_api_request_error(exception_class): 164 | callable = RunnableCounter(exception_class("Something went wrong.")) 165 | with testing_retries(max_attempts=5, wait=0) as func: 166 | with pytest.raises(exception_class): 167 | func(callable) 168 | assert callable.call_count == 5 169 | 170 | 171 | def test_stops_after_successful_retry(): 172 | side_effects = (APIRequestError("Something went wrong."), sentinel.result) 173 | callable = RunnableCounter(side_effects) 174 | with testing_retries(max_attempts=5, wait=0) as func: 175 | result = func(callable) 176 | assert callable.call_count == 2 177 | assert result == sentinel.result 178 | 179 | 180 | def test_does_not_retry_if_successful_on_first_attempt(): 181 | callable = RunnableCounter(sentinel.result) 182 | with testing_retries(max_attempts=5, wait=0) as func: 183 | result = func(callable) 184 | assert callable.call_count == 1 185 | assert result == sentinel.result 186 | 187 | 188 | def test_does_not_retry_when_not_api_request_error(): 189 | callable = RunnableCounter(ValueError("Not an APIRequestError.")) 190 | with testing_retries(max_attempts=5, wait=0) as func: 191 | with pytest.raises(ValueError): 192 | func(callable) 193 | assert callable.call_count == 1 194 | 195 | 196 | @pytest.mark.parametrize("status_code", [500, 501, 599]) 197 | def test_retries_for_status_codes_over_5xx(status_code): 198 | side_effects = (APIRequestError("Something went wrong.", status_code), sentinel.result) 199 | callable = RunnableCounter(side_effects) 200 | with testing_retries(max_attempts=5, wait=0) as func: 201 | result = func(callable) 202 | assert callable.call_count == 2 203 | assert result == sentinel.result 204 | 205 | 206 | @pytest.mark.parametrize("status_code", [300, 400, 499]) 207 | def test_does_not_retry_for_status_codes_under_5xx(status_code): 208 | side_effects = (APIRequestError("Something went wrong.", status_code), sentinel.result) 209 | callable = RunnableCounter(side_effects) 210 | with testing_retries(max_attempts=5, wait=0) as func: 211 | with pytest.raises(APIRequestError): 212 | func(callable) 213 | assert callable.call_count == 1 214 | 215 | 216 | @pytest.mark.parametrize("status_code", [0, 200, 300, 400, 500, 600, 1000]) 217 | def test_retry_on_status_codes(status_code): 218 | side_effects = APIRequestError("Something went wrong.", status_code) 219 | callable = RunnableCounter(side_effects) 220 | with testing_retry_for_status_code(status_codes=[status_code]) as func: 221 | with pytest.raises(APIRequestError): 222 | func(callable) 223 | assert callable.call_count == 2 224 | -------------------------------------------------------------------------------- /tests/test_warnings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from apiclient.utils.warnings import deprecation_warning 4 | 5 | 6 | def test_deprecation_warning(): 7 | with pytest.warns(DeprecationWarning) as record: 8 | deprecation_warning("a warning") 9 | assert len(record) == 1 10 | # check that the message matches 11 | assert record[0].message.args[0] == "[APIClient] a warning" 12 | -------------------------------------------------------------------------------- /tests/vcr_cassettes/cassette.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: ['*/*'] 6 | Accept-Encoding: ['gzip, deflate'] 7 | Connection: [keep-alive] 8 | Content-type: [application/json] 9 | User-Agent: [python-requests/2.21.0] 10 | method: GET 11 | uri: http://testserver/users 12 | response: 13 | body: 14 | string: '[ 15 | {"userId": 1, "firstName": "Mike", "lastName": "Foo"}, 16 | {"userId": 2, "firstName": "Sarah", "lastName": "Bar"}, 17 | {"userId": 3, "firstName": "Barry", "lastName": "Baz"} 18 | ]' 19 | headers: 20 | Content-Type: [application/json; charset=utf-8] 21 | status: {code: 200, message: OK} 22 | - request: 23 | body: null 24 | headers: 25 | Accept: ['*/*'] 26 | Accept-Encoding: ['gzip, deflate'] 27 | Connection: [keep-alive] 28 | Content-type: [application/json] 29 | User-Agent: [python-requests/2.21.0] 30 | method: GET 31 | uri: http://testserver/users/1 32 | response: 33 | body: 34 | string: '{"userId": 1, "firstName": "Mike", "lastName": "Foo"}' 35 | headers: 36 | Content-Type: [application/json; charset=utf-8] 37 | status: {code: 200, message: OK} 38 | - request: 39 | body: null 40 | headers: 41 | Accept: ['*/*'] 42 | Accept-Encoding: ['gzip, deflate'] 43 | Connection: [keep-alive] 44 | Content-type: [application/json] 45 | User-Agent: [python-requests/2.21.0] 46 | method: GET 47 | uri: http://testserver/users/2 48 | response: 49 | body: null 50 | headers: 51 | Content-Type: [application/json; charset=utf-8] 52 | status: {code: 500, message: SERVER_ERROR} 53 | - request: 54 | body: null 55 | headers: 56 | Accept: ['*/*'] 57 | Accept-Encoding: ['gzip, deflate'] 58 | Connection: [keep-alive] 59 | Content-type: [application/json] 60 | User-Agent: [python-requests/2.21.0] 61 | method: GET 62 | uri: http://testserver/users/2 63 | response: 64 | body: 65 | string: '{"userId": 2, "firstName": "Sarah", "lastName": "Bar"}' 66 | headers: 67 | Content-Type: [application/json; charset=utf-8] 68 | status: {code: 200, message: OK} 69 | - request: 70 | body: null 71 | headers: 72 | Accept: ['*/*'] 73 | Accept-Encoding: ['gzip, deflate'] 74 | Connection: [keep-alive] 75 | Content-type: [application/json] 76 | User-Agent: [python-requests/2.21.0] 77 | method: POST 78 | uri: http://testserver/users 79 | response: 80 | body: 81 | string: '{"userId": 4, "firstName": "Lucy", "lastName": "Qux"}' 82 | headers: 83 | Content-Type: [application/json; charset=utf-8] 84 | status: {code: 201, message: CREATED} 85 | - request: 86 | body: null 87 | headers: 88 | Accept: ['*/*'] 89 | Accept-Encoding: ['gzip, deflate'] 90 | Connection: [keep-alive] 91 | Content-type: [application/json] 92 | User-Agent: [python-requests/2.21.0] 93 | method: PUT 94 | uri: http://testserver/users/4 95 | response: 96 | body: 97 | string: '{"userId": 4, "firstName": "Lucy", "lastName": "Foo"}' 98 | headers: 99 | Content-Type: [application/json; charset=utf-8] 100 | status: {code: 200, message: OK} 101 | - request: 102 | body: null 103 | headers: 104 | Accept: ['*/*'] 105 | Accept-Encoding: ['gzip, deflate'] 106 | Connection: [keep-alive] 107 | Content-type: [application/json] 108 | User-Agent: [python-requests/2.21.0] 109 | method: PATCH 110 | uri: http://testserver/users/4 111 | response: 112 | body: 113 | string: '{"userId": 4, "firstName": "Lucy", "lastName": "Qux"}' 114 | headers: 115 | Content-Type: [application/json; charset=utf-8] 116 | status: {code: 200, message: OK} 117 | - request: 118 | body: null 119 | headers: 120 | Accept: ['*/*'] 121 | Accept-Encoding: ['gzip, deflate'] 122 | Connection: [keep-alive] 123 | Content-type: [application/json] 124 | User-Agent: [python-requests/2.21.0] 125 | method: DELETE 126 | uri: http://testserver/users/4 127 | response: 128 | body: null 129 | headers: 130 | Content-Type: [application/json; charset=utf-8] 131 | status: {code: 200, message: OK} 132 | - request: 133 | body: null 134 | headers: 135 | Accept: ['*/*'] 136 | Accept-Encoding: ['gzip, deflate'] 137 | Connection: [keep-alive] 138 | Content-type: [application/json] 139 | User-Agent: [python-requests/2.21.0] 140 | method: GET 141 | uri: http://testserver/accounts?userId=1 142 | response: 143 | body: 144 | string: '{ 145 | "results": [ 146 | {"accountName": "business", "number": "1234"}, 147 | {"accountName": "expense", "number": "2345"} 148 | ], 149 | "page": 1, 150 | "nextPage": 2 151 | }' 152 | headers: 153 | Content-Type: [application/json; charset=utf-8] 154 | status: {code: 200, message: OK} 155 | - request: 156 | body: null 157 | headers: 158 | Accept: ['*/*'] 159 | Accept-Encoding: ['gzip, deflate'] 160 | Connection: [keep-alive] 161 | Content-type: [application/json] 162 | User-Agent: [python-requests/2.21.0] 163 | method: GET 164 | uri: http://testserver/accounts?userId=1&page=2 165 | response: 166 | body: 167 | string: '{ 168 | "results": [ 169 | {"accountName": "fun", "number": "6544"}, 170 | {"accountName": "holiday", "number": "9283"} 171 | ], 172 | "page": 2, 173 | "nextPage": 3 174 | }' 175 | headers: 176 | Content-Type: [application/json; charset=utf-8] 177 | status: {code: 200, message: OK} 178 | - request: 179 | body: null 180 | headers: 181 | Accept: ['*/*'] 182 | Accept-Encoding: ['gzip, deflate'] 183 | Connection: [keep-alive] 184 | Content-type: [application/json] 185 | User-Agent: [python-requests/2.21.0] 186 | method: GET 187 | uri: http://testserver/accounts?userId=1&page=3 188 | response: 189 | body: 190 | string: '{ 191 | "results": [ 192 | {"accountName": "gifts", "number": "7827"}, 193 | {"accountName": "home", "number": "1259"} 194 | ], 195 | "page": 3, 196 | "nextPage": null 197 | }' 198 | headers: 199 | Content-Type: [application/json; charset=utf-8] 200 | status: {code: 200, message: OK} 201 | - request: 202 | body: null 203 | headers: 204 | Accept: ['*/*'] 205 | Accept-Encoding: ['gzip, deflate'] 206 | Connection: [keep-alive] 207 | Content-type: [application/json] 208 | User-Agent: [python-requests/2.21.0] 209 | method: GET 210 | uri: http://testserver/accounts?userId=10 211 | response: 212 | body: 213 | string: '{"error_code": 20001, "message": "AAAA Internal error"}' 214 | headers: 215 | Content-Type: [application/json; charset=utf-8] 216 | status: {code: 500, message: SERVER ERROR} 217 | - request: 218 | body: null 219 | headers: 220 | Accept: ['*/*'] 221 | Accept-Encoding: ['gzip, deflate'] 222 | Connection: [keep-alive] 223 | Content-type: [application/json] 224 | User-Agent: [python-requests/2.21.0] 225 | method: GET 226 | uri: http://testserver/accounts?userId=10 227 | response: 228 | body: 229 | string: '{"error_code": 20002, "message": "some mistake"}' 230 | headers: 231 | Content-Type: [application/json; charset=utf-8] 232 | status: {code: 400, message: CLIENT ERROR} 233 | - request: 234 | body: null 235 | headers: 236 | Accept: ['*/*'] 237 | Accept-Encoding: ['gzip, deflate'] 238 | Connection: [keep-alive] 239 | Content-type: [application/json] 240 | User-Agent: [python-requests/2.21.0] 241 | method: GET 242 | uri: http://testserver/accounts?userId=10 243 | response: 244 | body: 245 | string: '{"error_code": "no_code", "message": "some mistake"}' 246 | headers: 247 | Content-Type: [application/json; charset=utf-8] 248 | status: {code: 500, message: SERVER ERROR} 249 | - request: 250 | body: null 251 | headers: 252 | Accept: ['*/*'] 253 | Accept-Encoding: ['gzip, deflate'] 254 | Connection: [keep-alive] 255 | Content-type: [application/json] 256 | User-Agent: [python-requests/2.21.0] 257 | method: GET 258 | uri: http://testserver/accounts?userId=10 259 | response: 260 | body: 261 | string: '

504 Gateway Time-out

The server didn`t respond in time. ' 262 | headers: 263 | Content-Type: [text/html; charset=utf-8] 264 | status: {code: 504, message: SERVER ERROR} 265 | - request: 266 | body: null 267 | headers: 268 | Accept: ['*/*'] 269 | Accept-Encoding: ['gzip, deflate'] 270 | Connection: [keep-alive] 271 | Content-type: [application/json] 272 | User-Agent: [python-requests/2.21.0] 273 | method: GET 274 | uri: http://testserver/accounts?userId=10 275 | response: 276 | body: 277 | string: '' 278 | headers: 279 | Content-Type: [application/json; charset=utf-8] 280 | status: {code: 500, message: SERVER ERROR} 281 | 282 | version: 1 283 | -------------------------------------------------------------------------------- /tests/vcr_cassettes/error_cassette.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: ['*/*'] 6 | Accept-Encoding: ['gzip, deflate'] 7 | Connection: [keep-alive] 8 | Content-type: [application/json] 9 | User-Agent: [python-requests/2.21.0] 10 | method: GET 11 | uri: http://testserver/users/1 12 | response: 13 | body: 14 | string: '' 15 | headers: 16 | Content-Type: [application/json; charset=utf-8] 17 | status: {code: 300, message: REDIRECT} 18 | - request: 19 | body: null 20 | headers: 21 | Accept: ['*/*'] 22 | Accept-Encoding: ['gzip, deflate'] 23 | Connection: [keep-alive] 24 | Content-type: [application/json] 25 | User-Agent: [python-requests/2.21.0] 26 | method: GET 27 | uri: http://testserver/users/2 28 | response: 29 | body: 30 | string: '' 31 | headers: 32 | Content-Type: [application/json; charset=utf-8] 33 | status: {code: 399, message: REDIRECT} 34 | - request: 35 | body: null 36 | headers: 37 | Accept: ['*/*'] 38 | Accept-Encoding: ['gzip, deflate'] 39 | Connection: [keep-alive] 40 | Content-type: [application/json] 41 | User-Agent: [python-requests/2.21.0] 42 | method: GET 43 | uri: http://testserver/users/3 44 | response: 45 | body: 46 | string: '' 47 | headers: 48 | Content-Type: [application/json; charset=utf-8] 49 | status: {code: 400, message: CLIENT ERROR} 50 | - request: 51 | body: null 52 | headers: 53 | Accept: ['*/*'] 54 | Accept-Encoding: ['gzip, deflate'] 55 | Connection: [keep-alive] 56 | Content-type: [application/json] 57 | User-Agent: [python-requests/2.21.0] 58 | method: GET 59 | uri: http://testserver/users/4 60 | response: 61 | body: 62 | string: '' 63 | headers: 64 | Content-Type: [application/json; charset=utf-8] 65 | status: {code: 499, message: CLIENT ERROR} 66 | - request: 67 | body: null 68 | headers: 69 | Accept: ['*/*'] 70 | Accept-Encoding: ['gzip, deflate'] 71 | Connection: [keep-alive] 72 | Content-type: [application/json] 73 | User-Agent: [python-requests/2.21.0] 74 | method: GET 75 | uri: http://testserver/users/5 76 | response: 77 | body: 78 | string: '' 79 | headers: 80 | Content-Type: [application/json; charset=utf-8] 81 | status: {code: 500, message: SERVER ERROR} 82 | - request: 83 | body: null 84 | headers: 85 | Accept: ['*/*'] 86 | Accept-Encoding: ['gzip, deflate'] 87 | Connection: [keep-alive] 88 | Content-type: [application/json] 89 | User-Agent: [python-requests/2.21.0] 90 | method: GET 91 | uri: http://testserver/users/6 92 | response: 93 | body: 94 | string: '' 95 | headers: 96 | Content-Type: [application/json; charset=utf-8] 97 | status: {code: 599, message: SERVER ERROR} 98 | - request: 99 | body: null 100 | headers: 101 | Accept: ['*/*'] 102 | Accept-Encoding: ['gzip, deflate'] 103 | Connection: [keep-alive] 104 | Content-type: [application/json] 105 | User-Agent: [python-requests/2.21.0] 106 | method: GET 107 | uri: http://testserver/users/7 108 | response: 109 | body: 110 | string: '' 111 | headers: 112 | Content-Type: [application/json; charset=utf-8] 113 | status: {code: 600, message: UNEXPECTED} 114 | - request: 115 | body: null 116 | headers: 117 | Accept: ['*/*'] 118 | Accept-Encoding: ['gzip, deflate'] 119 | Connection: [keep-alive] 120 | Content-type: [application/json] 121 | User-Agent: [python-requests/2.21.0] 122 | method: GET 123 | uri: http://testserver/users/8 124 | response: 125 | body: 126 | string: '' 127 | headers: 128 | Content-Type: [application/json; charset=utf-8] 129 | status: {code: 999, message: UNEXPECTED} 130 | 131 | version: 1 132 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | requests{216,217,218,219,220,221,222,223,224,225,226}-tenacity{51,60,61,62,63,70,80} 4 | lint 5 | 6 | [testenv] 7 | deps = 8 | requests216: requests>=2.16,<2.17 9 | requests217: requests>=2.17,<2.18 10 | requests218: requests>=2.18,<2.19 11 | requests219: requests>=2.19,<2.20 12 | requests220: requests>=2.20,<2.21 13 | requests221: requests>=2.21,<2.22 14 | requests222: requests>=2.22,<2.23 15 | requests223: requests>=2.23,<2.24 16 | requests224: requests>=2.24,<2.25 17 | requests225: requests>=2.25,<2.26 18 | requests226: requests>=2.26,<2.27 19 | tenacity51: tenacity>=5.1.0,<5.2.0 20 | tenacity60: tenacity>=6.0.0,<6.1.0 21 | tenacity61: tenacity>=6.1.0,<6.2.0 22 | tenacity62: tenacity>=6.2.0,<6.3.0 23 | tenacity63: tenacity>=6.3.0,<6.4.0 24 | tenacity70: tenacity>=7.0.0,<7.1.0 25 | tenacity80: tenacity>=8.0.0,<8.1.0 26 | extras = test 27 | commands = 28 | pytest {posargs:{toxinidir}/tests} 29 | 30 | [testenv:lint] 31 | extras = lint 32 | install_command = python -m pip install {opts} {packages} 33 | commands = 34 | {envbindir}/isort {toxinidir}/apiclient {toxinidir}/tests --check-only 35 | {envbindir}/black --check {toxinidir}/apiclient {toxinidir}/tests 36 | {envbindir}/flake8 {toxinidir}/apiclient {toxinidir}/tests 37 | -------------------------------------------------------------------------------- /upload_new_package.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyPi package uploader that doesn't return a bad status code 3 | if the package already exists. 4 | """ 5 | import sys 6 | 7 | from requests import HTTPError 8 | from twine.cli import dispatch 9 | 10 | 11 | def main(): 12 | try: 13 | return dispatch(["upload", "dist/*"]) 14 | except HTTPError as error: 15 | handle_http_error(error) 16 | 17 | 18 | def handle_http_error(error: HTTPError): 19 | try: 20 | if error.response.status_code == 400: 21 | print(error) 22 | else: 23 | raise error 24 | except Exception: 25 | raise error 26 | 27 | 28 | if __name__ == "__main__": 29 | sys.exit(main()) 30 | --------------------------------------------------------------------------------