├── .github └── workflows │ ├── leaked-secrets-scan.yml │ ├── pull_requests.yml │ ├── release.yml │ └── semgrep.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── poetry.lock ├── pyproject.toml ├── setup.cfg ├── src └── sns_extended_client │ ├── __init__.py │ ├── exceptions.py │ └── session.py ├── test ├── __init__.py └── test_session.py └── test_integ ├── __init__.py ├── fixtures ├── __init__.py ├── objects.py ├── session.py └── sns.py └── test_session.py /.github/workflows/leaked-secrets-scan.yml: -------------------------------------------------------------------------------- 1 | name: Leaked Secrets Scan 2 | on: [pull_request] 3 | jobs: 4 | TruffleHog: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout code 8 | uses: actions/checkout@v3 9 | with: 10 | fetch-depth: 0 11 | - name: TruffleHog OSS 12 | uses: trufflesecurity/trufflehog@1594fddf051a1a0e7e59d25c27bc20d39a27d349 # v3.44.0 13 | with: 14 | path: ./ 15 | base: ${{ github.event.repository.default_branch }} 16 | head: HEAD 17 | extra_args: --debug --only-verified 18 | -------------------------------------------------------------------------------- /.github/workflows/pull_requests.yml: -------------------------------------------------------------------------------- 1 | name: PullRequest 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | id-token: write 14 | steps: 15 | - name: Check out the repository 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 2 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: "3.9" 23 | - name: Install Poetry 24 | run: | 25 | pip install poetry 26 | poetry --version 27 | - name: Build package 28 | run: | 29 | poetry build 30 | - name: Install package 31 | run: | 32 | poetry install 33 | - name: Run pytest 34 | run: | 35 | poetry run pytest --cov=sns_extended_client test --cov-report term-missing -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | AWS_REGION: us-east-1 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | id-token: write 17 | steps: 18 | - name: Check out the repository 19 | uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 2 22 | - name: Set up Python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: "3.9" 26 | - name: Install Poetry 27 | run: | 28 | pip install poetry 29 | poetry --version 30 | - name: Check if there is a parent commit 31 | id: check-parent-commit 32 | run: | 33 | echo "sha=$(git rev-parse --verify --quiet HEAD^)" >> $GITHUB_OUTPUT 34 | - name: Detect and tag new version 35 | id: check-version 36 | if: steps.check-parent-commit.outputs.sha 37 | uses: salsify/action-detect-and-tag-new-version@v2 38 | with: 39 | version-command: | 40 | bash -o pipefail -c "poetry version | awk '{ print \$2 }'" 41 | - name: Bump version for developmental release 42 | if: "! steps.check-version.outputs.tag" 43 | run: | 44 | poetry version patch && 45 | version=$(poetry version | awk '{ print $2 }') && 46 | poetry version $version.dev.$(date +%s) 47 | - name: Build package 48 | run: | 49 | poetry build 50 | - name: Install package 51 | run: | 52 | poetry install 53 | - name: Run pytest 54 | run: | 55 | poetry run pytest --cov=sns_extended_client test --cov-report term-missing 56 | - name: configure aws credentials 57 | uses: aws-actions/configure-aws-credentials@v4 58 | with: 59 | role-to-assume: ${{ vars.OIDC_ROLE_NAME }} 60 | role-session-name: publishrolesession 61 | aws-region: ${{ env.AWS_REGION }} 62 | - name: Run Integration Tests 63 | run: | 64 | poetry run pytest test_integ 65 | - name: Retrieve TEST PYPI TOKEN from secretsmanager 66 | id: get-test-pypi-token 67 | if: "! steps.check-version.outputs.tag" 68 | run: | 69 | echo "token=$(aws secretsmanager get-secret-value --secret-id ${{ vars.TEST_PYPI_TOKEN_NAME }} | jq -r '.SecretString')" >> $GITHUB_OUTPUT 70 | - name: Retrieve PYPI TOKEN from secretsmanager 71 | id: get-pypi-token 72 | if: steps.check-version.outputs.tag 73 | run: | 74 | echo "token=$(aws secretsmanager get-secret-value --secret-id ${{ vars.PYPI_TOKEN_NAME }} | jq -r '.SecretString')" >> $GITHUB_OUTPUT 75 | - name: Publish package on TestPyPI 76 | if: "! steps.check-version.outputs.tag" 77 | uses: pypa/gh-action-pypi-publish@release/v1 78 | with: 79 | user: __token__ 80 | password: ${{ steps.get-test-pypi-token.outputs.token }} 81 | repository-url: https://test.pypi.org/legacy/ 82 | - name: Publish package on PyPI 83 | if: steps.check-version.outputs.tag 84 | uses: pypa/gh-action-pypi-publish@release/v1 85 | with: 86 | user: __token__ 87 | password: ${{ steps.get-pypi-token.outputs.token }} 88 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | # Name of this GitHub Actions workflow. 2 | name: Semgrep 3 | 4 | on: 5 | # Scan changed files in PRs (diff-aware scanning): 6 | pull_request: {} 7 | # Scan mainline branches and report all findings: 8 | push: 9 | branches: ["master", "main"] 10 | # Schedule the CI job (this method uses cron syntax): 11 | schedule: 12 | - cron: '30 20 * * *' # Sets Semgrep to scan every day at 17:20 UTC. 13 | # It is recommended to change the schedule to a random time. 14 | 15 | jobs: 16 | semgrep: 17 | # User-definable name of this GitHub Actions job: 18 | name: Scan 19 | # If you are self-hosting, change the following `runs-on` value: 20 | runs-on: ubuntu-latest 21 | 22 | container: 23 | # A Docker image with Semgrep installed. Do not change this. 24 | image: returntocorp/semgrep 25 | 26 | # Skip any PR created by dependabot to avoid permission issues: 27 | if: (github.actor != 'dependabot[bot]') 28 | 29 | steps: 30 | # Fetch project source with GitHub Actions Checkout. 31 | - uses: actions/checkout@v3 32 | # Run the "semgrep ci" command on the command line of the docker image. 33 | - run: semgrep ci 34 | env: 35 | # Add the rules that Semgrep uses by setting the SEMGREP_RULES environment variable. 36 | SEMGREP_RULES: p/default # more at semgrep.dev/explore 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .coverage 3 | .pytest_cache 4 | __pycache__ 5 | *.DS_Store 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := bash 2 | .ONESHELL: 3 | .SHELLFLAGS := -eu -o pipefail -c 4 | .DELETE_ON_ERROR: 5 | MAKEFLAGS += --warn-undefined-variables 6 | MAKEFLAGS += --no-builtin-rules 7 | 8 | install: # Install the app locally 9 | poetry install 10 | .PHONY: install 11 | 12 | ci: lint test ## Run all checks (test, lint) 13 | .PHONY: ci 14 | 15 | test: ## Run tests 16 | poetry run pytest --cov=sns_extended_client --cov-report term-missing test 17 | .PHONY: test 18 | 19 | lint: ## Run linting 20 | poetry run black --check src test 21 | poetry run isort -c src test 22 | poetry run flake8 src test 23 | .PHONY: lint 24 | 25 | lint-fix: ## Run autoformatters 26 | poetry run black src test 27 | poetry run isort src test 28 | .PHONY: lint-fix 29 | 30 | .DEFAULT_GOAL := help 31 | help: Makefile 32 | @grep -E '(^[a-zA-Z_-]+:.*?##.*$$)|(^##)' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m##/[33m/' 33 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon SNS Extended Client Library for Python 2 | 3 | ### Implements the functionality of [amazon-sns-java-extended-client-lib](https://github.com/awslabs/amazon-sns-java-extended-client-lib) in Python 4 | 5 | ## Getting Started 6 | 7 | * **Sign up for AWS** -- Before you begin, you need an AWS account. For more information about creating an AWS account, see [create and activate aws account](https://aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/). 8 | * **Minimum requirements** -- Python 3.x (or later) and pip 9 | * **Download** -- Download the latest preview release or pick it up from pip: 10 | ``` 11 | pip install amazon-sns-extended-client 12 | ``` 13 | 14 | 15 | ## Overview 16 | sns-extended-client allows for publishing large messages through SNS via S3. This is the same mechanism that the Amazon library 17 | [amazon-sns-java-extended-client-lib](https://github.com/awslabs/amazon-sns-java-extended-client-lib) provides. 18 | 19 | ## Additional attributes available on `boto3` SNS `client`, `Topic` and `PlatformEndpoint` objects. 20 | * large_payload_support -- the S3 bucket name that will store large messages. 21 | * use_legacy_attribute -- if `True`, then all published messages use the Legacy reserved message attribute (SQSLargePayloadSize) instead of the current reserved message attribute (ExtendedPayloadSize). 22 | * message_size_threshold -- the threshold for storing the message in the large messages bucket. Cannot be less than `0` or greater than `262144`. Defaults to `262144`. 23 | * always_through_s3 -- if `True`, then all messages will be serialized to S3. Defaults to `False` 24 | * s3_client -- the boto3 S3 `client` object to use to store objects to S3. Use this if you want to control the S3 client (for example, custom S3 config or credentials). Defaults to `boto3.client("s3")` on first use if not previously set. 25 | 26 | ## Usage 27 | 28 | #### Note: 29 | > The s3 bucket must already exist prior to usage, and be accessible by whatever credentials you have available 30 | 31 | ### Enabling support for large payloads (>256Kb) 32 | 33 | ```python 34 | import boto3 35 | import sns_extended_client 36 | 37 | # Low level client 38 | sns = boto3.client('sns') 39 | sns.large_payload_support = 'bucket-name' 40 | 41 | # boto SNS.Topic resource 42 | resource = boto3.resource('sns') 43 | topic = resource.Topic('topic-arn') 44 | 45 | # Or 46 | topic = resource.create_topic(Name='topic-name') 47 | 48 | topic.large_payload_support = 'my-bucket-name' 49 | 50 | # boto SNS.PlatformEndpoint resource 51 | resource = boto3.resource('sns') 52 | platform_endpoint = resource.PlatformEndpoint('endpoint-arn') 53 | 54 | platform_endpoint.large_payload_support = 'my-bucket-name' 55 | ``` 56 | 57 | ### Enabling support for large payloads (>64K) 58 | ```python 59 | import boto3 60 | import sns_extended_client 61 | 62 | # Low level client 63 | sns = boto3.client('sns') 64 | sns.large_payload_support = 'BUCKET-NAME' 65 | sns.message_size_threshold = 65536 66 | 67 | # boto SNS.Topic resource 68 | resource = boto3.resource('sns') 69 | topic = resource.Topic('topic-arn') 70 | 71 | # Or 72 | topic = resource.create_topic('topic-name') 73 | 74 | topic.large_payload_support = 'bucket-name' 75 | topic.message_size_threshold = 65536 76 | 77 | # boto SNS.PlatformEndpoint resource 78 | resource = boto3.resource('sns') 79 | platform_endpoint = resource.PlatformEndpoint('endpoint-arn') 80 | 81 | platform_endpoint.large_payload_support = 'my-bucket-name' 82 | platform_endpoint.message_size_threshold = 65536 83 | ``` 84 | ### Enabling support for large payloads for all messages 85 | ```python 86 | import boto3 87 | import sns_extended_client 88 | 89 | # Low level client 90 | sns = boto3.client('sns') 91 | sns.large_payload_support = 'my-bucket-name' 92 | sns.always_through_s3 = True 93 | 94 | # boto SNS.Topic resource 95 | resource = boto3.resource('sns') 96 | topic = resource.Topic('topic-arn') 97 | 98 | # Or 99 | topic = resource.create_topic(Name='topic-name') 100 | 101 | topic.large_payload_support = 'my-bucket-name' 102 | topic.always_through_s3 = True 103 | 104 | # boto SNS.PlatformEndpoint resource 105 | resource = boto3.resource('sns') 106 | platform_endpoint = resource.PlatformEndpoint('endpoint-arn') 107 | 108 | platform_endpoint.large_payload_support = 'my-bucket-name' 109 | platform_endpoint.always_through_s3 = True 110 | ``` 111 | ### Setting a custom S3 config 112 | ```python 113 | import boto3 114 | from botocore.config import Config 115 | import sns_extended_client 116 | 117 | # Define Configuration for boto3's S3 Client 118 | # NOTE - The boto3 version from 1.36.0 to 1.36.6 will throw an error if you enable accelerate_endpoint. 119 | s3_client_config = Config( 120 | region_name = 'us-east-1', 121 | signature_version = 's3v4', 122 | s3={ 123 | "use_accelerate_endpoint":True 124 | } 125 | ) 126 | 127 | # Low level client 128 | sns = boto3.client('sns') 129 | sns.large_payload_support = 'my-bucket-name' 130 | sns.s3_client = boto3.client("s3", config=s3_client_config) 131 | 132 | # boto SNS.Topic resource 133 | resource = boto3.resource('sns') 134 | topic = resource.Topic('topic-arn') 135 | 136 | # Or 137 | topic = resource.topic(Name='topic-name') 138 | 139 | topic.large_payload_support = 'my-bucket-name' 140 | topic.s3_client = boto3.client("s3", config=s3_client_config) 141 | 142 | # boto SNS.PlatformEndpoint resource 143 | resource = boto3.resource('sns') 144 | platform_endpoint = resource.PlatformEndpoint('endpoint-arn') 145 | 146 | platform_endpoint.large_payload_support = 'my-bucket-name' 147 | platform_endpoint.s3_client = boto3.client("s3", config=s3_client_config) 148 | ``` 149 | 150 | ### Setting a custom S3 Key 151 | Publish Message Supports user defined S3 Key used to store objects in the specified Bucket. 152 | 153 | To use custom keys add the S3 key as a Message Attribute in the MessageAttributes argument with the MessageAttribute. 154 | 155 | **Key - "S3Key"** 156 | ```python 157 | sns.publish( 158 | Message="message", 159 | MessageAttributes={ 160 | "S3Key": { 161 | "DataType": "String", 162 | "StringValue": "--S3--Key--", 163 | } 164 | }, 165 | ) 166 | ``` 167 | 168 | ### Using SQSLargePayloadSize as reserved message attribute 169 | Initial versions of the Java SNS Extended Client used 'SQSLargePayloadSize' as the reserved message attribute to determine that a message is an S3 message. 170 | 171 | In the later versions it was changed to use 'ExtendedPayloadSize'. 172 | 173 | To use the Legacy reserved message attribute set use_legacy_attribute parameter to `True`. 174 | 175 | ```python 176 | import boto3 177 | import sns_extended_client 178 | 179 | # Low level client 180 | sns = boto3.client('sns') 181 | sns.large_payload_support = 'bucket-name' 182 | 183 | sns.use_legacy_attribute = True 184 | 185 | # boto SNS.Topic resource 186 | resource = boto3.resource('sns') 187 | topic = resource.Topic('topic-arn') 188 | 189 | # Or 190 | topic = resource.create_topic(Name='topic-name') 191 | 192 | topic.large_payload_support = 'my-bucket-name' 193 | topic.use_legacy_attribute = True 194 | 195 | # boto SNS.PlatformEndpoint resource 196 | resource = boto3.resource('sns') 197 | platform_endpoint = resource.PlatformEndpoint('endpoint-arn') 198 | 199 | platform_endpoint.large_payload_support = 'my-bucket-name' 200 | platform_endpoint.use_legacy_attribute = True 201 | ``` 202 | 203 | ## CODE SAMPLE 204 | Here is an example of using the extended payload utility: 205 | 206 | Here we create an SNS Topic and SQS Queue, then subscribe the queue to the topic. 207 | 208 | We publish messages to the created Topic and print the published message from the queue along with the original message retrieved from S3. 209 | 210 | ```python 211 | import boto3 212 | from sns_extended_client import SNSExtendedClientSession 213 | from json import loads 214 | 215 | s3_extended_payload_bucket = "extended-client-bucket-store" # S3 bucket with the given bucket name is a resource which is created and accessible with the given AWS credentials 216 | TOPIC_NAME = "---TOPIC-NAME---" 217 | QUEUE_NAME = "---QUEUE-NAME---" 218 | 219 | def allow_sns_to_write_to_sqs(topicarn, queuearn): 220 | policy_document = """{{ 221 | "Version":"2012-10-17", 222 | "Statement":[ 223 | {{ 224 | "Sid":"MyPolicy", 225 | "Effect":"Allow", 226 | "Principal" : {{"AWS" : "*"}}, 227 | "Action":"SQS:SendMessage", 228 | "Resource": "{}", 229 | "Condition":{{ 230 | "ArnEquals":{{ 231 | "aws:SourceArn": "{}" 232 | }} 233 | }} 234 | }} 235 | ] 236 | }}""".format(queuearn, topicarn) 237 | 238 | return policy_document 239 | 240 | def get_msg_from_s3(body,sns_extended_client): 241 | """Handy Helper to fetch message from S3""" 242 | json_msg = loads(body) 243 | s3_object = sns_extended_client.s3_client.get_object( 244 | Bucket=json_msg[1].get("s3BucketName"), Key=json_msg[1].get("s3Key") 245 | ) 246 | msg = s3_object.get("Body").read().decode() 247 | return msg 248 | 249 | 250 | def fetch_and_print_from_sqs(sqs, queue_url,sns_extended_client): 251 | sqs_msg = sqs.receive_message( 252 | QueueUrl=queue_url, 253 | AttributeNames=['All'], 254 | MessageAttributeNames=['All'], 255 | VisibilityTimeout=0, 256 | WaitTimeSeconds=0, 257 | MaxNumberOfMessages=1 258 | ).get("Messages")[0] 259 | 260 | message_body = sqs_msg.get("Body") 261 | print("Published Message: {}".format(message_body)) 262 | print("Message Stored in S3 Bucket is: {}\n".format(get_msg_from_s3(message_body,sns_extended_client))) 263 | 264 | # Delete the Processed Message 265 | sqs.delete_message( 266 | QueueUrl=queue_url, 267 | ReceiptHandle=sqs_msg['ReceiptHandle'] 268 | ) 269 | 270 | 271 | sns_extended_client = boto3.client("sns", region_name="us-east-1") 272 | create_topic_response = sns_extended_client.create_topic(Name=TOPIC_NAME) 273 | sns_topic_arn = create_topic_response.get("TopicArn") 274 | 275 | # create and subscribe an sqs queue to the sns client 276 | sqs = boto3.client("sqs",region_name="us-east-1") 277 | demo_queue_url = sqs.create_queue(QueueName=QUEUE_NAME).get("QueueUrl") 278 | sqs_queue_arn = sqs.get_queue_attributes( 279 | QueueUrl=demo_queue_url, AttributeNames=["QueueArn"] 280 | )["Attributes"].get("QueueArn") 281 | 282 | # Adding policy to SQS queue such that SNS topic can send msg to SQS queue 283 | policy_json = allow_sns_to_write_to_sqs(sns_topic_arn, sqs_queue_arn) 284 | response = sqs.set_queue_attributes( 285 | QueueUrl = demo_queue_url, 286 | Attributes = { 287 | 'Policy' : policy_json 288 | } 289 | ) 290 | 291 | # Set the RawMessageDelivery subscription attribute to TRUE if you want to use 292 | # SQSExtendedClient to help with retrieving msg from S3 293 | sns_extended_client.subscribe(TopicArn=sns_topic_arn, Protocol="sqs", 294 | Endpoint=sqs_queue_arn 295 | , Attributes={"RawMessageDelivery":"true"} 296 | ) 297 | 298 | sns_extended_client.large_payload_support = s3_extended_payload_bucket 299 | 300 | # Change default s3_client attribute of sns_extended_client to use 'us-east-1' region 301 | sns_extended_client.s3_client = boto3.client("s3", region_name="us-east-1") 302 | 303 | 304 | # Below is the example that all the messages will be sent to the S3 bucket 305 | sns_extended_client.always_through_s3 = True 306 | sns_extended_client.publish( 307 | TopicArn=sns_topic_arn, Message="This message should be published to S3" 308 | ) 309 | print("\n\nPublished using SNS extended client:") 310 | fetch_and_print_from_sqs(sqs, demo_queue_url,sns_extended_client) # Prints message stored in s3 311 | 312 | # Below is the example that all the messages larger than 32 bytes will be sent to the S3 bucket 313 | print("\nUsing decreased message size threshold:") 314 | 315 | sns_extended_client.always_through_s3 = False 316 | sns_extended_client.message_size_threshold = 32 317 | sns_extended_client.publish( 318 | TopicArn=sns_topic_arn, 319 | Message="This message should be published to S3 as it exceeds the limit of the 32 bytes", 320 | ) 321 | 322 | fetch_and_print_from_sqs(sqs, demo_queue_url,sns_extended_client) # Prints message stored in s3 323 | 324 | 325 | # Below is the example to publish message using the SNS.Topic resource 326 | sns_extended_client_resource = SNSExtendedClientSession().resource( 327 | "sns", region_name="us-east-1" 328 | ) 329 | 330 | topic = sns_extended_client_resource.Topic(sns_topic_arn) 331 | topic.large_payload_support = s3_extended_payload_bucket 332 | 333 | # Change default s3_client attribute of topic to use 'us-east-1' region 334 | topic.s3_client = boto3.client("s3", region_name="us-east-1") 335 | 336 | topic.always_through_s3 = True 337 | # Can Set custom S3 Keys to be used to store objects in S3 338 | topic.publish( 339 | Message="This message should be published to S3 using the topic resource", 340 | MessageAttributes={ 341 | "S3Key": { 342 | "DataType": "String", 343 | "StringValue": "347c11c4-a22c-42e4-a6a2-9b5af5b76587", 344 | } 345 | }, 346 | ) 347 | print("\nPublished using Topic Resource:") 348 | fetch_and_print_from_sqs(sqs, demo_queue_url,topic) 349 | 350 | # Below is the example to publish message using the SNS.PlatformEndpoint resource 351 | sns_extended_client_resource = SNSExtendedClientSession().resource( 352 | "sns", region_name="us-east-1" 353 | ) 354 | 355 | platform_endpoint = sns_extended_client_resource.PlatformEndpoint(sns_topic_arn) 356 | platform_endpoint.large_payload_support = s3_extended_payload_bucket 357 | 358 | # Change default s3_client attribute of platform_endpoint to use 'us-east-1' region 359 | platform_endpoint.s3_client = boto3.client("s3", region_name="us-east-1") 360 | 361 | platform_endpoint.always_through_s3 = True 362 | # Can Set custom S3 Keys to be used to store objects in S3 363 | platform_endpoint.publish( 364 | Message="This message should be published to S3 using the PlatformEndpoint resource", 365 | MessageAttributes={ 366 | "S3Key": { 367 | "DataType": "String", 368 | "StringValue": "247c11c4-a22c-42e4-a6a2-9b5af5b76587", 369 | } 370 | }, 371 | ) 372 | print("\nPublished using PlatformEndpoint Resource:") 373 | fetch_and_print_from_sqs(sqs, demo_queue_url,platform_endpoint) 374 | ``` 375 | 376 | PRODUCED OUTPUT: 377 | ``` 378 | Published using SNS extended client: 379 | Published Message: ["software.amazon.payloadoffloading.PayloadS3Pointer", {"s3BucketName": "extended-client-bucket-store", "s3Key": "10999f58-c5ae-4d68-9208-f70475e0113d"}] 380 | Message Stored in S3 Bucket is: This message should be published to S3 381 | 382 | Using decreased message size threshold: 383 | Published Message: ["software.amazon.payloadoffloading.PayloadS3Pointer", {"s3BucketName": "extended-client-bucket-store", "s3Key": "2c5cb2c7-e649-492b-85fb-fa9923cb02bf"}] 384 | Message Stored in S3 Bucket is: This message should be published to S3 as it exceeds the limit of the 32 bytes 385 | 386 | Published using Topic Resource: 387 | Published Message: ["software.amazon.payloadoffloading.PayloadS3Pointer", {"s3BucketName": "extended-client-bucket-store", "s3Key": "347c11c4-a22c-42e4-a6a2-9b5af5b76587"}] 388 | Message Stored in S3 Bucket is: This message should be published to S3 using the topic resource 389 | 390 | Published using PlatformEndpoint Resource: 391 | Published Message: ["software.amazon.payloadoffloading.PayloadS3Pointer", {"s3BucketName": "extended-client-bucket-store", "s3Key": "247c11c4-a22c-42e4-a6a2-9b5af5b76587"}] 392 | Message Stored in S3 Bucket is: This message should be published to S3 using the PlatformEndpoint resource 393 | ``` 394 | 395 | ## DEVELOPMENT 396 | 397 | We have built-in Makefile to run test, format check or fix in one command. Please check [Makefile](Makefile) for more information. 398 | 399 | Just run below command, and it will do format check and run unit test: 400 | ``` 401 | make ci 402 | ``` 403 | 404 | ## Security 405 | 406 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 407 | 408 | ## License 409 | 410 | This project is licensed under the Apache-2.0 License. -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "black" 5 | version = "23.3.0" 6 | description = "The uncompromising code formatter." 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, 11 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, 12 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, 13 | {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, 14 | {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, 15 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, 16 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, 17 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, 18 | {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, 19 | {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, 20 | {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, 21 | {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, 22 | {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, 23 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, 24 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, 25 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, 26 | {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, 27 | {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, 28 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, 29 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, 30 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, 31 | {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, 32 | {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, 33 | {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, 34 | {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, 35 | ] 36 | 37 | [package.dependencies] 38 | click = ">=8.0.0" 39 | mypy-extensions = ">=0.4.3" 40 | packaging = ">=22.0" 41 | pathspec = ">=0.9.0" 42 | platformdirs = ">=2" 43 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 44 | typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} 45 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 46 | 47 | [package.extras] 48 | colorama = ["colorama (>=0.4.3)"] 49 | d = ["aiohttp (>=3.7.4)"] 50 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 51 | uvloop = ["uvloop (>=0.15.2)"] 52 | 53 | [[package]] 54 | name = "boto3" 55 | version = "1.33.13" 56 | description = "The AWS SDK for Python" 57 | optional = false 58 | python-versions = ">= 3.7" 59 | files = [ 60 | {file = "boto3-1.33.13-py3-none-any.whl", hash = "sha256:5f278b95fb2b32f3d09d950759a05664357ba35d81107bab1537c4ddd212cd8c"}, 61 | {file = "boto3-1.33.13.tar.gz", hash = "sha256:0e966b8a475ecb06cc0846304454b8da2473d4c8198a45dfb2c5304871986883"}, 62 | ] 63 | 64 | [package.dependencies] 65 | botocore = ">=1.33.13,<1.34.0" 66 | jmespath = ">=0.7.1,<2.0.0" 67 | s3transfer = ">=0.8.2,<0.9.0" 68 | 69 | [package.extras] 70 | crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] 71 | 72 | [[package]] 73 | name = "botocore" 74 | version = "1.33.13" 75 | description = "Low-level, data-driven core of boto 3." 76 | optional = false 77 | python-versions = ">= 3.7" 78 | files = [ 79 | {file = "botocore-1.33.13-py3-none-any.whl", hash = "sha256:aeadccf4b7c674c7d47e713ef34671b834bc3e89723ef96d994409c9f54666e6"}, 80 | {file = "botocore-1.33.13.tar.gz", hash = "sha256:fb577f4cb175605527458b04571451db1bd1a2036976b626206036acd4496617"}, 81 | ] 82 | 83 | [package.dependencies] 84 | jmespath = ">=0.7.1,<2.0.0" 85 | python-dateutil = ">=2.1,<3.0.0" 86 | urllib3 = [ 87 | {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, 88 | {version = ">=1.25.4,<2.1", markers = "python_version >= \"3.10\""}, 89 | ] 90 | 91 | [package.extras] 92 | crt = ["awscrt (==0.19.17)"] 93 | 94 | [[package]] 95 | name = "certifi" 96 | version = "2025.1.31" 97 | description = "Python package for providing Mozilla's CA Bundle." 98 | optional = false 99 | python-versions = ">=3.6" 100 | files = [ 101 | {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, 102 | {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, 103 | ] 104 | 105 | [[package]] 106 | name = "cffi" 107 | version = "1.15.1" 108 | description = "Foreign Function Interface for Python calling C code." 109 | optional = false 110 | python-versions = "*" 111 | files = [ 112 | {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, 113 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, 114 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, 115 | {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, 116 | {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, 117 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, 118 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, 119 | {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, 120 | {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, 121 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, 122 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, 123 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, 124 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, 125 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, 126 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, 127 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, 128 | {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, 129 | {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, 130 | {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, 131 | {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, 132 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, 133 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, 134 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, 135 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, 136 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, 137 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, 138 | {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, 139 | {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, 140 | {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, 141 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, 142 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, 143 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, 144 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, 145 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, 146 | {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, 147 | {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, 148 | {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, 149 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, 150 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, 151 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, 152 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, 153 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, 154 | {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, 155 | {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, 156 | {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, 157 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, 158 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, 159 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, 160 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, 161 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, 162 | {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, 163 | {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, 164 | {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, 165 | {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, 166 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, 167 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, 168 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, 169 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, 170 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, 171 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, 172 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, 173 | {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, 174 | {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, 175 | {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, 176 | ] 177 | 178 | [package.dependencies] 179 | pycparser = "*" 180 | 181 | [[package]] 182 | name = "charset-normalizer" 183 | version = "3.4.1" 184 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 185 | optional = false 186 | python-versions = ">=3.7" 187 | files = [ 188 | {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, 189 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, 190 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, 191 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, 192 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, 193 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, 194 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, 195 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, 196 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, 197 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, 198 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, 199 | {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, 200 | {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, 201 | {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, 202 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, 203 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, 204 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, 205 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, 206 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, 207 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, 208 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, 209 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, 210 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, 211 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, 212 | {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, 213 | {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, 214 | {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, 215 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, 216 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, 217 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, 218 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, 219 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, 220 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, 221 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, 222 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, 223 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, 224 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, 225 | {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, 226 | {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, 227 | {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, 228 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, 229 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, 230 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, 231 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, 232 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, 233 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, 234 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, 235 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, 236 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, 237 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, 238 | {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, 239 | {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, 240 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, 241 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, 242 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, 243 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, 244 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, 245 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, 246 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, 247 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, 248 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, 249 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, 250 | {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, 251 | {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, 252 | {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, 253 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, 254 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, 255 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, 256 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, 257 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, 258 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, 259 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, 260 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, 261 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, 262 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, 263 | {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, 264 | {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, 265 | {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, 266 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, 267 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, 268 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, 269 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, 270 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, 271 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, 272 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, 273 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, 274 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, 275 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, 276 | {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, 277 | {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, 278 | {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, 279 | {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, 280 | ] 281 | 282 | [[package]] 283 | name = "click" 284 | version = "8.1.8" 285 | description = "Composable command line interface toolkit" 286 | optional = false 287 | python-versions = ">=3.7" 288 | files = [ 289 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 290 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 291 | ] 292 | 293 | [package.dependencies] 294 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 295 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 296 | 297 | [[package]] 298 | name = "colorama" 299 | version = "0.4.6" 300 | description = "Cross-platform colored terminal text." 301 | optional = false 302 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 303 | files = [ 304 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 305 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 306 | ] 307 | 308 | [[package]] 309 | name = "coverage" 310 | version = "7.2.7" 311 | description = "Code coverage measurement for Python" 312 | optional = false 313 | python-versions = ">=3.7" 314 | files = [ 315 | {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, 316 | {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, 317 | {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, 318 | {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, 319 | {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, 320 | {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, 321 | {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, 322 | {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, 323 | {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, 324 | {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, 325 | {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, 326 | {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, 327 | {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, 328 | {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, 329 | {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, 330 | {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, 331 | {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, 332 | {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, 333 | {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, 334 | {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, 335 | {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, 336 | {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, 337 | {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, 338 | {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, 339 | {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, 340 | {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, 341 | {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, 342 | {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, 343 | {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, 344 | {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, 345 | {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, 346 | {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, 347 | {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, 348 | {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, 349 | {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, 350 | {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, 351 | {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, 352 | {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, 353 | {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, 354 | {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, 355 | {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, 356 | {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, 357 | {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, 358 | {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, 359 | {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, 360 | {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, 361 | {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, 362 | {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, 363 | {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, 364 | {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, 365 | {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, 366 | {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, 367 | {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, 368 | {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, 369 | {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, 370 | {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, 371 | {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, 372 | {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, 373 | {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, 374 | {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, 375 | ] 376 | 377 | [package.dependencies] 378 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 379 | 380 | [package.extras] 381 | toml = ["tomli"] 382 | 383 | [[package]] 384 | name = "cryptography" 385 | version = "43.0.3" 386 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 387 | optional = false 388 | python-versions = ">=3.7" 389 | files = [ 390 | {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, 391 | {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, 392 | {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, 393 | {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, 394 | {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, 395 | {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, 396 | {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, 397 | {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, 398 | {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, 399 | {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, 400 | {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, 401 | {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, 402 | {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, 403 | {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, 404 | {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, 405 | {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, 406 | {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, 407 | {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, 408 | {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, 409 | {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, 410 | {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, 411 | {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, 412 | {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, 413 | {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, 414 | {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, 415 | {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, 416 | {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, 417 | ] 418 | 419 | [package.dependencies] 420 | cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} 421 | 422 | [package.extras] 423 | docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] 424 | docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] 425 | nox = ["nox"] 426 | pep8test = ["check-sdist", "click", "mypy", "ruff"] 427 | sdist = ["build"] 428 | ssh = ["bcrypt (>=3.1.5)"] 429 | test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] 430 | test-randomorder = ["pytest-randomly"] 431 | 432 | [[package]] 433 | name = "exceptiongroup" 434 | version = "1.2.2" 435 | description = "Backport of PEP 654 (exception groups)" 436 | optional = false 437 | python-versions = ">=3.7" 438 | files = [ 439 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 440 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 441 | ] 442 | 443 | [package.extras] 444 | test = ["pytest (>=6)"] 445 | 446 | [[package]] 447 | name = "flake8" 448 | version = "3.9.2" 449 | description = "the modular source code checker: pep8 pyflakes and co" 450 | optional = false 451 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 452 | files = [ 453 | {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, 454 | {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, 455 | ] 456 | 457 | [package.dependencies] 458 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 459 | mccabe = ">=0.6.0,<0.7.0" 460 | pycodestyle = ">=2.7.0,<2.8.0" 461 | pyflakes = ">=2.3.0,<2.4.0" 462 | 463 | [[package]] 464 | name = "flake8" 465 | version = "5.0.4" 466 | description = "the modular source code checker: pep8 pyflakes and co" 467 | optional = false 468 | python-versions = ">=3.6.1" 469 | files = [ 470 | {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"}, 471 | {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"}, 472 | ] 473 | 474 | [package.dependencies] 475 | mccabe = ">=0.7.0,<0.8.0" 476 | pycodestyle = ">=2.9.0,<2.10.0" 477 | pyflakes = ">=2.5.0,<2.6.0" 478 | 479 | [[package]] 480 | name = "idna" 481 | version = "3.10" 482 | description = "Internationalized Domain Names in Applications (IDNA)" 483 | optional = false 484 | python-versions = ">=3.6" 485 | files = [ 486 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 487 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 488 | ] 489 | 490 | [package.extras] 491 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 492 | 493 | [[package]] 494 | name = "importlib-metadata" 495 | version = "6.7.0" 496 | description = "Read metadata from Python packages" 497 | optional = false 498 | python-versions = ">=3.7" 499 | files = [ 500 | {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, 501 | {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, 502 | ] 503 | 504 | [package.dependencies] 505 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 506 | zipp = ">=0.5" 507 | 508 | [package.extras] 509 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 510 | perf = ["ipython"] 511 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] 512 | 513 | [[package]] 514 | name = "iniconfig" 515 | version = "2.0.0" 516 | description = "brain-dead simple config-ini parsing" 517 | optional = false 518 | python-versions = ">=3.7" 519 | files = [ 520 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 521 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 522 | ] 523 | 524 | [[package]] 525 | name = "isort" 526 | version = "5.11.5" 527 | description = "A Python utility / library to sort Python imports." 528 | optional = false 529 | python-versions = ">=3.7.0" 530 | files = [ 531 | {file = "isort-5.11.5-py3-none-any.whl", hash = "sha256:ba1d72fb2595a01c7895a5128f9585a5cc4b6d395f1c8d514989b9a7eb2a8746"}, 532 | {file = "isort-5.11.5.tar.gz", hash = "sha256:6be1f76a507cb2ecf16c7cf14a37e41609ca082330be4e3436a18ef74add55db"}, 533 | ] 534 | 535 | [package.extras] 536 | colors = ["colorama (>=0.4.3,<0.5.0)"] 537 | pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] 538 | plugins = ["setuptools"] 539 | requirements-deprecated-finder = ["pip-api", "pipreqs"] 540 | 541 | [[package]] 542 | name = "isort" 543 | version = "5.13.2" 544 | description = "A Python utility / library to sort Python imports." 545 | optional = false 546 | python-versions = ">=3.8.0" 547 | files = [ 548 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 549 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 550 | ] 551 | 552 | [package.extras] 553 | colors = ["colorama (>=0.4.6)"] 554 | 555 | [[package]] 556 | name = "jinja2" 557 | version = "3.1.5" 558 | description = "A very fast and expressive template engine." 559 | optional = false 560 | python-versions = ">=3.7" 561 | files = [ 562 | {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, 563 | {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, 564 | ] 565 | 566 | [package.dependencies] 567 | MarkupSafe = ">=2.0" 568 | 569 | [package.extras] 570 | i18n = ["Babel (>=2.7)"] 571 | 572 | [[package]] 573 | name = "jmespath" 574 | version = "1.0.1" 575 | description = "JSON Matching Expressions" 576 | optional = false 577 | python-versions = ">=3.7" 578 | files = [ 579 | {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, 580 | {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, 581 | ] 582 | 583 | [[package]] 584 | name = "markupsafe" 585 | version = "2.1.5" 586 | description = "Safely add untrusted strings to HTML/XML markup." 587 | optional = false 588 | python-versions = ">=3.7" 589 | files = [ 590 | {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, 591 | {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, 592 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, 593 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, 594 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, 595 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, 596 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, 597 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, 598 | {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, 599 | {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, 600 | {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, 601 | {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, 602 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, 603 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, 604 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, 605 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, 606 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, 607 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, 608 | {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, 609 | {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, 610 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, 611 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, 612 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, 613 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, 614 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, 615 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, 616 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, 617 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, 618 | {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, 619 | {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, 620 | {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, 621 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, 622 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, 623 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, 624 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, 625 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, 626 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, 627 | {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, 628 | {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, 629 | {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, 630 | {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, 631 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, 632 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, 633 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, 634 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, 635 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, 636 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, 637 | {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, 638 | {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, 639 | {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, 640 | {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, 641 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, 642 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, 643 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, 644 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, 645 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, 646 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, 647 | {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, 648 | {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, 649 | {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, 650 | ] 651 | 652 | [[package]] 653 | name = "mccabe" 654 | version = "0.6.1" 655 | description = "McCabe checker, plugin for flake8" 656 | optional = false 657 | python-versions = "*" 658 | files = [ 659 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 660 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 661 | ] 662 | 663 | [[package]] 664 | name = "mccabe" 665 | version = "0.7.0" 666 | description = "McCabe checker, plugin for flake8" 667 | optional = false 668 | python-versions = ">=3.6" 669 | files = [ 670 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 671 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 672 | ] 673 | 674 | [[package]] 675 | name = "moto" 676 | version = "4.2.14" 677 | description = "" 678 | optional = false 679 | python-versions = ">=3.7" 680 | files = [ 681 | {file = "moto-4.2.14-py2.py3-none-any.whl", hash = "sha256:6d242dbbabe925bb385ddb6958449e5c827670b13b8e153ed63f91dbdb50372c"}, 682 | {file = "moto-4.2.14.tar.gz", hash = "sha256:8f9263ca70b646f091edcc93e97cda864a542e6d16ed04066b1370ed217bd190"}, 683 | ] 684 | 685 | [package.dependencies] 686 | boto3 = ">=1.9.201" 687 | botocore = ">=1.12.201" 688 | cryptography = ">=3.3.1" 689 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 690 | Jinja2 = ">=2.10.1" 691 | python-dateutil = ">=2.1,<3.0.0" 692 | requests = ">=2.5" 693 | responses = ">=0.13.0" 694 | werkzeug = ">=0.5,<2.2.0 || >2.2.0,<2.2.1 || >2.2.1" 695 | xmltodict = "*" 696 | 697 | [package.extras] 698 | all = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] 699 | apigateway = ["PyYAML (>=5.1)", "ecdsa (!=0.15)", "openapi-spec-validator (>=0.5.0)", "python-jose[cryptography] (>=3.1.0,<4.0.0)"] 700 | apigatewayv2 = ["PyYAML (>=5.1)"] 701 | appsync = ["graphql-core"] 702 | awslambda = ["docker (>=3.0.0)"] 703 | batch = ["docker (>=3.0.0)"] 704 | cloudformation = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] 705 | cognitoidp = ["ecdsa (!=0.15)", "python-jose[cryptography] (>=3.1.0,<4.0.0)"] 706 | dynamodb = ["docker (>=3.0.0)", "py-partiql-parser (==0.5.0)"] 707 | dynamodbstreams = ["docker (>=3.0.0)", "py-partiql-parser (==0.5.0)"] 708 | ec2 = ["sshpubkeys (>=3.1.0)"] 709 | glue = ["pyparsing (>=3.0.7)"] 710 | iotdata = ["jsondiff (>=1.1.2)"] 711 | proxy = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=2.5.1)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] 712 | resourcegroupstaggingapi = ["PyYAML (>=5.1)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)"] 713 | s3 = ["PyYAML (>=5.1)", "py-partiql-parser (==0.5.0)"] 714 | s3crc32c = ["PyYAML (>=5.1)", "crc32c", "py-partiql-parser (==0.5.0)"] 715 | server = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "flask (!=2.2.0,!=2.2.1)", "flask-cors", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] 716 | ssm = ["PyYAML (>=5.1)"] 717 | xray = ["aws-xray-sdk (>=0.93,!=0.96)", "setuptools"] 718 | 719 | [[package]] 720 | name = "mypy-extensions" 721 | version = "1.0.0" 722 | description = "Type system extensions for programs checked with the mypy type checker." 723 | optional = false 724 | python-versions = ">=3.5" 725 | files = [ 726 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 727 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 728 | ] 729 | 730 | [[package]] 731 | name = "packaging" 732 | version = "24.0" 733 | description = "Core utilities for Python packages" 734 | optional = false 735 | python-versions = ">=3.7" 736 | files = [ 737 | {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, 738 | {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, 739 | ] 740 | 741 | [[package]] 742 | name = "pathspec" 743 | version = "0.11.2" 744 | description = "Utility library for gitignore style pattern matching of file paths." 745 | optional = false 746 | python-versions = ">=3.7" 747 | files = [ 748 | {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, 749 | {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, 750 | ] 751 | 752 | [[package]] 753 | name = "platformdirs" 754 | version = "4.0.0" 755 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 756 | optional = false 757 | python-versions = ">=3.7" 758 | files = [ 759 | {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, 760 | {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, 761 | ] 762 | 763 | [package.dependencies] 764 | typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.8\""} 765 | 766 | [package.extras] 767 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 768 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 769 | 770 | [[package]] 771 | name = "pluggy" 772 | version = "1.2.0" 773 | description = "plugin and hook calling mechanisms for python" 774 | optional = false 775 | python-versions = ">=3.7" 776 | files = [ 777 | {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, 778 | {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, 779 | ] 780 | 781 | [package.dependencies] 782 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 783 | 784 | [package.extras] 785 | dev = ["pre-commit", "tox"] 786 | testing = ["pytest", "pytest-benchmark"] 787 | 788 | [[package]] 789 | name = "pycodestyle" 790 | version = "2.7.0" 791 | description = "Python style guide checker" 792 | optional = false 793 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 794 | files = [ 795 | {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, 796 | {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, 797 | ] 798 | 799 | [[package]] 800 | name = "pycodestyle" 801 | version = "2.9.1" 802 | description = "Python style guide checker" 803 | optional = false 804 | python-versions = ">=3.6" 805 | files = [ 806 | {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"}, 807 | {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"}, 808 | ] 809 | 810 | [[package]] 811 | name = "pycparser" 812 | version = "2.21" 813 | description = "C parser in Python" 814 | optional = false 815 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 816 | files = [ 817 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 818 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 819 | ] 820 | 821 | [[package]] 822 | name = "pyflakes" 823 | version = "2.3.1" 824 | description = "passive checker of Python programs" 825 | optional = false 826 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 827 | files = [ 828 | {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, 829 | {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, 830 | ] 831 | 832 | [[package]] 833 | name = "pyflakes" 834 | version = "2.5.0" 835 | description = "passive checker of Python programs" 836 | optional = false 837 | python-versions = ">=3.6" 838 | files = [ 839 | {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"}, 840 | {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"}, 841 | ] 842 | 843 | [[package]] 844 | name = "pytest" 845 | version = "7.4.4" 846 | description = "pytest: simple powerful testing with Python" 847 | optional = false 848 | python-versions = ">=3.7" 849 | files = [ 850 | {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, 851 | {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, 852 | ] 853 | 854 | [package.dependencies] 855 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 856 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 857 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 858 | iniconfig = "*" 859 | packaging = "*" 860 | pluggy = ">=0.12,<2.0" 861 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 862 | 863 | [package.extras] 864 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 865 | 866 | [[package]] 867 | name = "pytest-cov" 868 | version = "4.1.0" 869 | description = "Pytest plugin for measuring coverage." 870 | optional = false 871 | python-versions = ">=3.7" 872 | files = [ 873 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 874 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 875 | ] 876 | 877 | [package.dependencies] 878 | coverage = {version = ">=5.2.1", extras = ["toml"]} 879 | pytest = ">=4.6" 880 | 881 | [package.extras] 882 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 883 | 884 | [[package]] 885 | name = "python-dateutil" 886 | version = "2.9.0.post0" 887 | description = "Extensions to the standard Python datetime module" 888 | optional = false 889 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 890 | files = [ 891 | {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, 892 | {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, 893 | ] 894 | 895 | [package.dependencies] 896 | six = ">=1.5" 897 | 898 | [[package]] 899 | name = "pyyaml" 900 | version = "6.0.1" 901 | description = "YAML parser and emitter for Python" 902 | optional = false 903 | python-versions = ">=3.6" 904 | files = [ 905 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, 906 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, 907 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, 908 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, 909 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, 910 | {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, 911 | {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, 912 | {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, 913 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, 914 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, 915 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, 916 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, 917 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, 918 | {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, 919 | {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, 920 | {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, 921 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, 922 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, 923 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, 924 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, 925 | {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, 926 | {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, 927 | {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, 928 | {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, 929 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, 930 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, 931 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, 932 | {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, 933 | {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, 934 | {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, 935 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, 936 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, 937 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, 938 | {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, 939 | {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, 940 | {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, 941 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, 942 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, 943 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, 944 | {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, 945 | {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, 946 | {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, 947 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, 948 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, 949 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, 950 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, 951 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, 952 | {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, 953 | {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, 954 | {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, 955 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 956 | ] 957 | 958 | [[package]] 959 | name = "requests" 960 | version = "2.31.0" 961 | description = "Python HTTP for Humans." 962 | optional = false 963 | python-versions = ">=3.7" 964 | files = [ 965 | {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, 966 | {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, 967 | ] 968 | 969 | [package.dependencies] 970 | certifi = ">=2017.4.17" 971 | charset-normalizer = ">=2,<4" 972 | idna = ">=2.5,<4" 973 | urllib3 = ">=1.21.1,<3" 974 | 975 | [package.extras] 976 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 977 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 978 | 979 | [[package]] 980 | name = "responses" 981 | version = "0.23.3" 982 | description = "A utility library for mocking out the `requests` Python library." 983 | optional = false 984 | python-versions = ">=3.7" 985 | files = [ 986 | {file = "responses-0.23.3-py3-none-any.whl", hash = "sha256:e6fbcf5d82172fecc0aa1860fd91e58cbfd96cee5e96da5b63fa6eb3caa10dd3"}, 987 | {file = "responses-0.23.3.tar.gz", hash = "sha256:205029e1cb334c21cb4ec64fc7599be48b859a0fd381a42443cdd600bfe8b16a"}, 988 | ] 989 | 990 | [package.dependencies] 991 | pyyaml = "*" 992 | requests = ">=2.30.0,<3.0" 993 | types-PyYAML = "*" 994 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 995 | urllib3 = ">=1.25.10,<3.0" 996 | 997 | [package.extras] 998 | tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-requests"] 999 | 1000 | [[package]] 1001 | name = "s3transfer" 1002 | version = "0.8.2" 1003 | description = "An Amazon S3 Transfer Manager" 1004 | optional = false 1005 | python-versions = ">= 3.7" 1006 | files = [ 1007 | {file = "s3transfer-0.8.2-py3-none-any.whl", hash = "sha256:c9e56cbe88b28d8e197cf841f1f0c130f246595e77ae5b5a05b69fe7cb83de76"}, 1008 | {file = "s3transfer-0.8.2.tar.gz", hash = "sha256:368ac6876a9e9ed91f6bc86581e319be08188dc60d50e0d56308ed5765446283"}, 1009 | ] 1010 | 1011 | [package.dependencies] 1012 | botocore = ">=1.33.2,<2.0a.0" 1013 | 1014 | [package.extras] 1015 | crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] 1016 | 1017 | [[package]] 1018 | name = "six" 1019 | version = "1.17.0" 1020 | description = "Python 2 and 3 compatibility utilities" 1021 | optional = false 1022 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 1023 | files = [ 1024 | {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, 1025 | {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, 1026 | ] 1027 | 1028 | [[package]] 1029 | name = "tomli" 1030 | version = "2.0.1" 1031 | description = "A lil' TOML parser" 1032 | optional = false 1033 | python-versions = ">=3.7" 1034 | files = [ 1035 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 1036 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 1037 | ] 1038 | 1039 | [[package]] 1040 | name = "typed-ast" 1041 | version = "1.5.5" 1042 | description = "a fork of Python 2 and 3 ast modules with type comment support" 1043 | optional = false 1044 | python-versions = ">=3.6" 1045 | files = [ 1046 | {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, 1047 | {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, 1048 | {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, 1049 | {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04"}, 1050 | {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d"}, 1051 | {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d"}, 1052 | {file = "typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02"}, 1053 | {file = "typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee"}, 1054 | {file = "typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18"}, 1055 | {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88"}, 1056 | {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2"}, 1057 | {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9"}, 1058 | {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8"}, 1059 | {file = "typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b"}, 1060 | {file = "typed_ast-1.5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:44f214394fc1af23ca6d4e9e744804d890045d1643dd7e8229951e0ef39429b5"}, 1061 | {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:118c1ce46ce58fda78503eae14b7664163aa735b620b64b5b725453696f2a35c"}, 1062 | {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4919b808efa61101456e87f2d4c75b228f4e52618621c77f1ddcaae15904fa"}, 1063 | {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fc2b8c4e1bc5cd96c1a823a885e6b158f8451cf6f5530e1829390b4d27d0807f"}, 1064 | {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:16f7313e0a08c7de57f2998c85e2a69a642e97cb32f87eb65fbfe88381a5e44d"}, 1065 | {file = "typed_ast-1.5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2b946ef8c04f77230489f75b4b5a4a6f24c078be4aed241cfabe9cbf4156e7e5"}, 1066 | {file = "typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e"}, 1067 | {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e"}, 1068 | {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311"}, 1069 | {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2"}, 1070 | {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4"}, 1071 | {file = "typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431"}, 1072 | {file = "typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a"}, 1073 | {file = "typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437"}, 1074 | {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede"}, 1075 | {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4"}, 1076 | {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6"}, 1077 | {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4"}, 1078 | {file = "typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b"}, 1079 | {file = "typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10"}, 1080 | {file = "typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814"}, 1081 | {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8"}, 1082 | {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274"}, 1083 | {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a"}, 1084 | {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba"}, 1085 | {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, 1086 | {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, 1087 | ] 1088 | 1089 | [[package]] 1090 | name = "types-pyyaml" 1091 | version = "6.0.12.12" 1092 | description = "Typing stubs for PyYAML" 1093 | optional = false 1094 | python-versions = "*" 1095 | files = [ 1096 | {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, 1097 | {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, 1098 | ] 1099 | 1100 | [[package]] 1101 | name = "typing-extensions" 1102 | version = "4.7.1" 1103 | description = "Backported and Experimental Type Hints for Python 3.7+" 1104 | optional = false 1105 | python-versions = ">=3.7" 1106 | files = [ 1107 | {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, 1108 | {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, 1109 | ] 1110 | 1111 | [[package]] 1112 | name = "urllib3" 1113 | version = "1.26.20" 1114 | description = "HTTP library with thread-safe connection pooling, file post, and more." 1115 | optional = false 1116 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" 1117 | files = [ 1118 | {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}, 1119 | {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}, 1120 | ] 1121 | 1122 | [package.extras] 1123 | brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] 1124 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] 1125 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 1126 | 1127 | [[package]] 1128 | name = "urllib3" 1129 | version = "2.0.7" 1130 | description = "HTTP library with thread-safe connection pooling, file post, and more." 1131 | optional = false 1132 | python-versions = ">=3.7" 1133 | files = [ 1134 | {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, 1135 | {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, 1136 | ] 1137 | 1138 | [package.extras] 1139 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 1140 | secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] 1141 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 1142 | zstd = ["zstandard (>=0.18.0)"] 1143 | 1144 | [[package]] 1145 | name = "werkzeug" 1146 | version = "2.2.3" 1147 | description = "The comprehensive WSGI web application library." 1148 | optional = false 1149 | python-versions = ">=3.7" 1150 | files = [ 1151 | {file = "Werkzeug-2.2.3-py3-none-any.whl", hash = "sha256:56433961bc1f12533306c624f3be5e744389ac61d722175d543e1751285da612"}, 1152 | {file = "Werkzeug-2.2.3.tar.gz", hash = "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe"}, 1153 | ] 1154 | 1155 | [package.dependencies] 1156 | MarkupSafe = ">=2.1.1" 1157 | 1158 | [package.extras] 1159 | watchdog = ["watchdog"] 1160 | 1161 | [[package]] 1162 | name = "xmltodict" 1163 | version = "0.14.2" 1164 | description = "Makes working with XML feel like you are working with JSON" 1165 | optional = false 1166 | python-versions = ">=3.6" 1167 | files = [ 1168 | {file = "xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac"}, 1169 | {file = "xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553"}, 1170 | ] 1171 | 1172 | [[package]] 1173 | name = "zipp" 1174 | version = "3.15.0" 1175 | description = "Backport of pathlib-compatible object wrapper for zip files" 1176 | optional = false 1177 | python-versions = ">=3.7" 1178 | files = [ 1179 | {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, 1180 | {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, 1181 | ] 1182 | 1183 | [package.extras] 1184 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 1185 | testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] 1186 | 1187 | [metadata] 1188 | lock-version = "2.0" 1189 | python-versions = "^3.7" 1190 | content-hash = "b441a1b20d0c22abc6438739805cdb81b454252b4557cbcd04079b4591fcecac" 1191 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "amazon-sns-extended-client" 3 | version = "1.0.1" 4 | description = "Python version of AWS SNS extended client to publish large payload message" 5 | authors = ["Amazon Web Service - SNS"] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | packages = [{include = "sns_extended_client", from="src"}] 9 | homepage = "https://github.com/awslabs/amazon-sns-python-extended-client-lib" 10 | repository = "https://github.com/awslabs/amazon-sns-python-extended-client-lib" 11 | classifiers=[ 12 | "Development Status :: 5 - Production/Stable", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: Apache Software License", 15 | "Programming Language :: Python :: 3.7", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | ] 19 | 20 | [tool.poetry.dependencies] 21 | python = "^3.7" 22 | boto3 = "^1.26.91" 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | pytest = "^7.3.2" 26 | pytest-cov = "^4.1.0" 27 | moto = "^4.1.11" 28 | black = "^23.1" 29 | flake8 = [ 30 | # https://github.com/python/importlib_metadata/issues/406 31 | { version = "*", python="^3.7" }, 32 | { version = ">=5", python= ">=3.8"}, 33 | ] 34 | isort = [ 35 | { version = "5.11.5", python="3.7" }, 36 | { version = "^5.11.6", python= ">=3.8"}, 37 | ] 38 | 39 | [tool.black] 40 | line-length = 100 41 | 42 | [tool.isort] 43 | profile = "black" 44 | ensure_newline_before_comments = true 45 | use_parentheses = true 46 | multi_line_output = 3 47 | 48 | [build-system] 49 | requires = ["poetry-core"] 50 | build-backend = "poetry.core.masonry.api" 51 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | # E501 is line length. Let Black deal with this (sometimes it can't split lines). 4 | E501, 5 | # not pep8, black adds line break before binary operator 6 | W503, 7 | 8 | [bdist_wheel] 9 | # This flag says that the code is written to work on both Python 2 and Python 10 | # 3. If at all possible, it is good practice to do this. If you cannot, you 11 | # will need to generate wheels for each Python version that you support. 12 | universal=1 13 | 14 | [metadata] 15 | description_file = README.md 16 | -------------------------------------------------------------------------------- /src/sns_extended_client/__init__.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | 3 | from .session import SNSExtendedClientSession 4 | 5 | setattr(boto3.session, "Session", SNSExtendedClientSession) 6 | 7 | # Now take care of the reference in the boto3.__init__ module 8 | setattr(boto3, "Session", SNSExtendedClientSession) 9 | 10 | # Now ensure that even the default session is our SNSExtendedClientSession 11 | if boto3.DEFAULT_SESSION: 12 | boto3.setup_default_session() -------------------------------------------------------------------------------- /src/sns_extended_client/exceptions.py: -------------------------------------------------------------------------------- 1 | class SNSExtendedClientException(Exception): 2 | """Base Class for all SNS Extended client Exceptions""" 3 | 4 | def __init__(self, *args, **kwargs): 5 | super().__init__(*args, **kwargs) 6 | 7 | 8 | class MissingPayloadOffloadingResource(SNSExtendedClientException): 9 | def __init__(self, *args, **kwargs): 10 | error_msg = "Undeclared/Missing S3 bucket name for payload offloading!" 11 | super().__init__(error_msg, *args, **kwargs) 12 | -------------------------------------------------------------------------------- /src/sns_extended_client/session.py: -------------------------------------------------------------------------------- 1 | from json import dumps, loads 2 | from uuid import uuid4 3 | 4 | import boto3 5 | 6 | import botocore.session 7 | 8 | import logging 9 | logger = logging.getLogger("sns_extended_client.client") 10 | logger.setLevel(logging.WARNING) 11 | 12 | from .exceptions import MissingPayloadOffloadingResource, SNSExtendedClientException 13 | 14 | DEFAULT_MESSAGE_SIZE_THRESHOLD = 262144 15 | MESSAGE_POINTER_CLASS = "software.amazon.payloadoffloading.PayloadS3Pointer" 16 | LEGACY_MESSAGE_POINTER_CLASS = "com.amazon.sqs.javamessaging.MessageS3Pointer" 17 | LEGACY_RESERVED_ATTRIBUTE_NAME = "SQSLargePayloadSize" 18 | RESERVED_ATTRIBUTE_NAME = "ExtendedPayloadSize" 19 | S3_KEY_ATTRIBUTE_NAME = "S3Key" 20 | MULTIPLE_PROTOCOL_MESSAGE_STRUCTURE = "json" 21 | MAX_ALLOWED_ATTRIBUTES = 10 - 1 # 10 for SQS and 1 reserved attribute 22 | 23 | 24 | def _delete_large_payload_support(self): 25 | if hasattr(self, "__s3_bucket_name"): 26 | del self.__s3_bucket_name 27 | 28 | 29 | def _get_large_payload_support(self): 30 | return getattr(self, "__s3_bucket_name", None) 31 | 32 | 33 | def _set_large_payload_support(self, s3_bucket_name: str): 34 | if not isinstance(s3_bucket_name, str): 35 | raise TypeError(f"Given s3 bucket name is not of type str: {s3_bucket_name}") 36 | if not s3_bucket_name: 37 | raise ValueError("Empty string is not a valid bucket name.") 38 | else: 39 | setattr(self, "__s3_bucket_name", s3_bucket_name) 40 | 41 | 42 | def _delete_messsage_size_threshold(self): 43 | setattr(self, "__message_size_threshold", DEFAULT_MESSAGE_SIZE_THRESHOLD) 44 | 45 | 46 | def _get_message_size_threshold(self): 47 | return getattr(self, "__message_size_threshold", DEFAULT_MESSAGE_SIZE_THRESHOLD) 48 | 49 | 50 | def _set_message_size_threshold(self, message_size_threshold: int): 51 | if not isinstance(message_size_threshold, int): 52 | raise TypeError(f"message size specified is not of type int: {message_size_threshold}") 53 | if not 0 <= message_size_threshold <= DEFAULT_MESSAGE_SIZE_THRESHOLD: 54 | raise ValueError( 55 | f"Valid range for message size is {0} - {DEFAULT_MESSAGE_SIZE_THRESHOLD}: message size {message_size_threshold} is out of bounds" 56 | ) 57 | 58 | setattr(self, "__message_size_threshold", message_size_threshold) 59 | 60 | 61 | def _delete_always_through_s3(self): 62 | setattr(self, "__always_through_s3", False) 63 | 64 | 65 | def _get_always_through_s3(self): 66 | return getattr(self, "__always_through_s3", False) 67 | 68 | 69 | def _set_always_through_s3(self, always_through_s3: bool): 70 | if not isinstance(always_through_s3, bool): 71 | raise TypeError(f"Not a Valid boolean value: {always_through_s3}") 72 | if always_through_s3 and not getattr(self, "large_payload_support", ""): 73 | raise MissingPayloadOffloadingResource() 74 | setattr(self, "__always_through_s3", always_through_s3) 75 | 76 | 77 | def _delete_use_legacy_attribute(self): 78 | setattr(self, "__always_through_s3", False) 79 | 80 | 81 | def _get_use_legacy_attribute(self): 82 | return getattr(self, "__use_legacy_attribute", False) 83 | 84 | 85 | def _set_use_legacy_attribute(self, use_legacy_attribute: bool): 86 | if not isinstance(use_legacy_attribute, bool): 87 | raise TypeError(f"Not a Valid boolean value: {use_legacy_attribute}") 88 | 89 | setattr(self, "__use_legacy_attribute", use_legacy_attribute) 90 | 91 | 92 | def _is_large_message(self, attributes: dict, encoded_body: bytes): 93 | total = 0 94 | for key, value in attributes.items(): 95 | total = total + len(key.encode()) 96 | if "DataType" in value: 97 | total = total + len(value["DataType"].encode()) 98 | if "StringValue" in value: 99 | total = total + len(value["StringValue"].encode()) 100 | if "BinaryValue" in value: 101 | total = total + len(value["BinaryValue"]) 102 | total = total + len(encoded_body) 103 | return self.message_size_threshold < total 104 | 105 | 106 | def _check_size_of_message_attributes(self, message_attributes: dict): 107 | total = 0 108 | for key, value in message_attributes.items(): 109 | total = total + len(key.encode()) 110 | if "DataType" in value: 111 | total = total + len(value["DataType"].encode()) 112 | if "StringValue" in value: 113 | total = total + len(value["StringValue"].encode()) 114 | if "BinaryValue" in value: 115 | total = total + len(value["BinaryValue"]) 116 | 117 | if total > self.message_size_threshold: 118 | raise SNSExtendedClientException( 119 | f"Message attributes size is greater than the message size threshold: {self.message_size_threshold} consider including payload in the message body" 120 | ) 121 | 122 | 123 | def _check_message_attributes(self, message_attributes: dict): 124 | num_message_attributes = len(message_attributes) 125 | 126 | if num_message_attributes > MAX_ALLOWED_ATTRIBUTES: 127 | error_message = f"Number of message attributes [{num_message_attributes}] exceeds the maximum allowed for large-payload messages [{MAX_ALLOWED_ATTRIBUTES}]." 128 | raise SNSExtendedClientException(error_message) 129 | 130 | 131 | def _get_s3_key(self, message_attributes: dict): 132 | if S3_KEY_ATTRIBUTE_NAME in message_attributes: 133 | return message_attributes[S3_KEY_ATTRIBUTE_NAME]["StringValue"] 134 | return str(uuid4()) 135 | 136 | 137 | def _create_reserved_message_attribute_value(self, encoded_body_size_string): 138 | return {"DataType": "Number", "StringValue": encoded_body_size_string} 139 | 140 | 141 | def _make_payload(self, message_attributes: dict, message_body, message_structure: str): 142 | message_attributes = loads(dumps(message_attributes)) 143 | encoded_body = message_body.encode() 144 | if self.large_payload_support and ( 145 | self.always_through_s3 or self._is_large_message(message_attributes, encoded_body) 146 | ): 147 | if message_structure == "json": 148 | raise SNSExtendedClientException( 149 | "SNS extended client does not support sending JSON messages." 150 | ) 151 | 152 | self._check_message_attributes(message_attributes) 153 | 154 | for attribute in ( 155 | RESERVED_ATTRIBUTE_NAME, 156 | LEGACY_RESERVED_ATTRIBUTE_NAME, 157 | ): 158 | if attribute in message_attributes: 159 | raise SNSExtendedClientException( 160 | f"Message attribute name {attribute} is reserved for use by SNS extended client." 161 | ) 162 | 163 | message_pointer_used = ( 164 | LEGACY_MESSAGE_POINTER_CLASS if self.use_legacy_attribute else MESSAGE_POINTER_CLASS 165 | ) 166 | 167 | attribute_name_used = ( 168 | LEGACY_RESERVED_ATTRIBUTE_NAME if self.use_legacy_attribute else RESERVED_ATTRIBUTE_NAME 169 | ) 170 | 171 | message_attributes[attribute_name_used] = self._create_reserved_message_attribute_value( 172 | str(len(encoded_body)) 173 | ) 174 | 175 | self._check_size_of_message_attributes(message_attributes) 176 | 177 | s3_key = self._get_s3_key(message_attributes) 178 | 179 | self.s3_client.put_object(Bucket=self.large_payload_support, Key=s3_key, Body=encoded_body) 180 | 181 | message_body = dumps( 182 | [ 183 | message_pointer_used, 184 | {"s3BucketName": self.large_payload_support, "s3Key": s3_key}, 185 | ] 186 | ) 187 | 188 | return message_attributes, message_body 189 | 190 | 191 | def _publish_decorator(func): 192 | def _publish(self, **kwargs): 193 | if ( 194 | "TopicArn" not in kwargs 195 | and "TargetArn" not in kwargs 196 | and not getattr(self, "arn", False) 197 | ): 198 | raise SNSExtendedClientException("Missing TopicArn: TopicArn is a required feild.") 199 | 200 | kwargs["MessageAttributes"], kwargs["Message"] = self._make_payload( 201 | kwargs.get("MessageAttributes", {}), 202 | kwargs["Message"], 203 | kwargs.get("MessageStructure",None), 204 | ) 205 | return func(self, **kwargs) 206 | 207 | return _publish 208 | 209 | 210 | 211 | class SNSExtendedClientSession(boto3.session.Session): 212 | 213 | """ 214 | A session stores configuration state and allows you to create service 215 | clients and resources. SNSExtendedClientSession extends the functionality 216 | of the boto3 Session object by using the .register event functionality. 217 | 218 | :type aws_access_key_id: string 219 | :param aws_access_key_id: AWS access key ID 220 | :type aws_secret_access_key: string 221 | :param aws_secret_access_key: AWS secret access key 222 | :type aws_session_token: string 223 | :param aws_session_token: AWS temporary session token 224 | :type region_name: string 225 | :param region_name: Default region when creating new connections 226 | :type botocore_session: botocore.session.Session 227 | :param botocore_session: Use this Botocore session instead of creating 228 | a new default one. 229 | :type profile_name: string 230 | :param profile_name: The name of a profile to use. If not given, then 231 | the default profile is used. 232 | 233 | """ 234 | 235 | 236 | def __init__( 237 | self, 238 | aws_access_key_id=None, 239 | aws_secret_access_key=None, 240 | aws_session_token=None, 241 | region_name=None, 242 | botocore_session=None, 243 | profile_name=None, 244 | ): 245 | if botocore_session is None: 246 | self._session = botocore.session.get_session() 247 | else: 248 | self._session = botocore_session 249 | 250 | self.add_custom_user_agent() 251 | 252 | 253 | 254 | super().__init__( 255 | aws_access_key_id=aws_access_key_id, 256 | aws_secret_access_key=aws_secret_access_key, 257 | aws_session_token=aws_session_token, 258 | region_name=region_name, 259 | botocore_session=self._session, 260 | profile_name=profile_name, 261 | ) 262 | 263 | # Adding Additional attributes for sns Client, Topic and PlatformEndpoint Objects 264 | self.events.register("creating-client-class.sns", self.add_custom_attributes) 265 | self.events.register( 266 | "creating-resource-class.sns.Topic", 267 | self.add_custom_attributes, 268 | ) 269 | self.events.register( 270 | "creating-resource-class.sns.PlatformEndpoint", 271 | self.add_custom_attributes, 272 | ) 273 | 274 | def add_custom_user_agent(self): 275 | # Attaching SNSExtendedClient Session to the HTTP headers 276 | 277 | user_agent_header = self.__class__.__name__ 278 | 279 | if self._session.user_agent_extra: 280 | self._session.user_agent_extra += " " + user_agent_header 281 | else: 282 | self._session.user_agent_extra = user_agent_header 283 | 284 | def add_custom_attributes(self,class_attributes,**kwargs): 285 | 286 | 287 | class_attributes["large_payload_support"] = property( 288 | _get_large_payload_support, 289 | _set_large_payload_support, 290 | _delete_large_payload_support, 291 | ) 292 | class_attributes["message_size_threshold"] = property( 293 | _get_message_size_threshold, 294 | _set_message_size_threshold, 295 | _delete_messsage_size_threshold, 296 | ) 297 | class_attributes["always_through_s3"] = property( 298 | _get_always_through_s3, 299 | _set_always_through_s3, 300 | _delete_always_through_s3, 301 | ) 302 | class_attributes["use_legacy_attribute"] = property( 303 | _get_use_legacy_attribute, 304 | _set_use_legacy_attribute, 305 | _delete_use_legacy_attribute, 306 | ) 307 | class_attributes["s3_client"] = super().client("s3") 308 | 309 | class_attributes[ 310 | "_create_reserved_message_attribute_value" 311 | ] = _create_reserved_message_attribute_value 312 | class_attributes["_is_large_message"] = _is_large_message 313 | class_attributes["_make_payload"] = _make_payload 314 | class_attributes["_get_s3_key"] = _get_s3_key 315 | 316 | # Adding the S3 client to the object 317 | 318 | class_attributes["_check_size_of_message_attributes"] = _check_size_of_message_attributes 319 | class_attributes["_check_message_attributes"] = _check_message_attributes 320 | class_attributes["publish"] = _publish_decorator(class_attributes["publish"]) -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # test package init 2 | -------------------------------------------------------------------------------- /test/test_session.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | import uuid 4 | from json import JSONDecodeError, dumps, loads 5 | from unittest.mock import create_autospec 6 | 7 | import boto3 8 | from moto import mock_s3, mock_sns, mock_sqs 9 | 10 | from sns_extended_client.exceptions import SNSExtendedClientException 11 | from sns_extended_client.session import DEFAULT_MESSAGE_SIZE_THRESHOLD, LEGACY_MESSAGE_POINTER_CLASS, LEGACY_RESERVED_ATTRIBUTE_NAME, MAX_ALLOWED_ATTRIBUTES, MESSAGE_POINTER_CLASS, RESERVED_ATTRIBUTE_NAME, SNSExtendedClientSession 12 | 13 | class TestSNSExtendedClient(unittest.TestCase): 14 | """Tests to check and verify function of the python SNS extended client""" 15 | 16 | @classmethod 17 | def setUpClass(cls): 18 | """setup method for the test class""" 19 | # test environment AWS credential setup 20 | # amazonq-ignore-next-line 21 | os.environ["AWS_DEFAULT_REGION"] = "us-east-1" 22 | cls.test_bucket_name = "test-bucket" 23 | cls.test_queue_name = "test-queue" 24 | cls.test_topic_name = "test-topic" 25 | # cls.mock_s3 = mock_s3() 26 | cls.mock_sqs = mock_sqs() 27 | cls.mock_sns = mock_sns() 28 | # cls.mock_s3.start() 29 | cls.mock_sqs.start() 30 | cls.mock_sns.start() 31 | 32 | # create sns and resource shared by all the test methods 33 | cls.sns = boto3.client("sns",region_name=os.environ["AWS_DEFAULT_REGION"]) 34 | create_topic_response = cls.sns.create_topic(Name=TestSNSExtendedClient.test_topic_name) 35 | cls.test_topic_arn = create_topic_response.get("TopicArn") 36 | 37 | # create sqs queue and subscribe to sns topic to test messages 38 | cls.sqs = boto3.client("sqs", region_name=os.environ["AWS_DEFAULT_REGION"]) 39 | cls.test_queue_url = cls.sqs.create_queue(QueueName=TestSNSExtendedClient.test_queue_name).get("QueueUrl") 40 | 41 | test_queue_arn = cls.sqs.get_queue_attributes( 42 | QueueUrl=cls.test_queue_url, AttributeNames=["QueueArn"] 43 | )["Attributes"].get("QueueArn") 44 | 45 | cls.sns.subscribe( 46 | TopicArn=cls.test_topic_arn, 47 | Protocol="sqs", 48 | Endpoint=test_queue_arn, 49 | ) 50 | 51 | # shared queue resource 52 | cls.test_sqs_client = cls.sqs 53 | 54 | return super().setUpClass() 55 | 56 | def initialize_extended_client_setup(self): 57 | """Helper function to initialize extended client session""" 58 | self.sns_extended_client = SNSExtendedClientSession().client("sns") 59 | self.sns_extended_client.large_payload_support = TestSNSExtendedClient.test_bucket_name 60 | 61 | def initialize_test_properties(self): 62 | """Setting up test properties for an object of the SQSExtendedClientSession""" 63 | self.small_message_body = "small message body" 64 | self.small_message_attribute = {"SMALL_MESSAGE_ATTRIBUTE": {"DataType": "String", "StringValue": self.small_message_body}} 65 | self.ATTRIBUTES_ADDED = [ 66 | "large_payload_support", 67 | "message_size_threshold", 68 | "always_through_s3", 69 | "s3_client", 70 | "_is_large_message", 71 | "_make_payload", 72 | "_get_s3_key", 73 | ] 74 | self.s3_resource = boto3.resource("s3", region_name=os.environ["AWS_DEFAULT_REGION"]) 75 | self.s3_resource.create_bucket(Bucket=TestSNSExtendedClient.test_bucket_name) 76 | self.large_msg_body = "x" * (DEFAULT_MESSAGE_SIZE_THRESHOLD + 1) 77 | self.s3_key = 'cc562f4d-c6f3-4b0c-bf29-007a902c391f' 78 | 79 | # Developing a message attribute with a S3 Key 80 | self.message_attributes_with_s3_key = self.small_message_attribute.copy() 81 | self.message_attributes_with_s3_key['S3Key'] = { 82 | 'StringValue': self.s3_key, 83 | 'DataType': 'String' 84 | } 85 | 86 | # Creating a copy of the message attributes since a new key, LEGACY_RESERVED_ATTRIBUTE_NAME or 87 | # RESERVED_ATTRIBUTE_NAME, will be added during the `send_message` call. This is useful 88 | # for all the tests testing receive_message. 89 | self.unmodified_message_attribute = self.message_attributes_with_s3_key.copy() 90 | 91 | @mock_s3 92 | def setUp(self) -> None: 93 | """setup function invoked before running every test method""" 94 | self.initialize_extended_client_setup() 95 | self.initialize_test_properties() 96 | # return super().setUp() 97 | 98 | def tearDown(self) -> None: 99 | """teardown function invoked after running every test method""" 100 | del self.sns_extended_client 101 | return super().tearDown() 102 | 103 | @classmethod 104 | def tearDownClass(cls): 105 | """TearDown of test class""" 106 | cls.mock_sns.stop() 107 | cls.mock_sqs.stop() 108 | 109 | return super().tearDownClass() 110 | 111 | def test_default_session_is_extended_client_session(self): 112 | """Test to verify if default boto3 session is changed on import of SNSExtendedClient""" 113 | assert boto3.session.Session == SNSExtendedClientSession 114 | 115 | def test_sns_client_attributes_added(self): 116 | """Check the attributes of SNSExtendedSession Client are available in the client""" 117 | sns_extended_client = self.sns_extended_client 118 | 119 | for attr in self.ATTRIBUTES_ADDED: 120 | print(attr) 121 | assert hasattr(sns_extended_client, attr) 122 | 123 | assert all((hasattr(sns_extended_client, attr) for attr in self.ATTRIBUTES_ADDED)) 124 | 125 | def test_platform_endpoint_resource_attributes_added(self): 126 | """Check the attributes of SNSExtendedSession PlatformEndpoint resource are available in the resource object""" 127 | sns_resource = SNSExtendedClientSession().resource("sns") 128 | topic = sns_resource.Topic("arn") 129 | 130 | assert all((hasattr(topic, attr) for attr in self.ATTRIBUTES_ADDED)) 131 | 132 | def test_topic_resource_attributes_added(self): 133 | """Check the attributes of SNSExtendedSession Topic resource are available in the resource object""" 134 | sns_resource = SNSExtendedClientSession().resource("sns") 135 | topic = sns_resource.PlatformEndpoint(self.test_topic_arn) 136 | 137 | assert all((hasattr(topic, attr) for attr in self.ATTRIBUTES_ADDED)) 138 | 139 | def test_publish_calls_make_payload(self): 140 | """Test SNSExtendedSession client publish API call invokes _make_payload method to change the message and message attributes""" 141 | sns_client = self.sns_extended_client 142 | 143 | modified_msg_attr = {"dummy": {"StringValue": "dummy", "DataType": "String"}} 144 | modified_msg = "dummy msg" 145 | modified_msg_attr_to_check = { 146 | "dummy": {"Type": "String", "Value": "dummy"} 147 | } # to be compatible with SQS queue message format 148 | 149 | make_payload_mock = create_autospec( 150 | sns_client._make_payload, 151 | return_value=(modified_msg_attr, modified_msg), 152 | ) 153 | 154 | sns_client._make_payload = make_payload_mock 155 | sns_client.publish(TopicArn=self.test_topic_arn, MessageAttributes={}, Message="test") 156 | # verify the call to _make_payload 157 | make_payload_mock.assert_called_once_with({}, "test", None) 158 | 159 | # fetch message from sqs queue and verify the modified msg is published 160 | messages = self.test_sqs_client.receive_message( 161 | QueueUrl=self.test_queue_url, MessageAttributeNames=["All"] 162 | ).get("Messages") 163 | self.assertTrue(self.has_msg_attributes(messages, modified_msg_attr_to_check)) 164 | self.assertTrue(self.has_msg_body(messages, modified_msg)) 165 | 166 | def test_topic_publish_calls_make_payload(self): 167 | """Test SNSExtendedSession Topic Resource publish API call invokes _make_payload method to change the message and message attributes""" 168 | topic_resource = SNSExtendedClientSession().resource("sns").Topic(self.test_topic_arn) 169 | 170 | modified_msg_attr = {"dummy": {"StringValue": "dummy", "DataType": "String"}} 171 | modified_msg = "dummy msg" 172 | modified_msg_attr_to_check = { 173 | "dummy": {"Type": "String", "Value": "dummy"} 174 | } # to be compatible with SQS queue message format 175 | 176 | make_payload_mock = create_autospec( 177 | topic_resource._make_payload, 178 | return_value=(modified_msg_attr, modified_msg), 179 | ) 180 | 181 | topic_resource._make_payload = make_payload_mock 182 | topic_resource.publish(MessageAttributes={}, Message="test") 183 | # verify the call to _make_payload 184 | make_payload_mock.assert_called_once_with({}, "test", None) 185 | 186 | # fetch message from sqs queue and verify the modified msg is published 187 | messages = self.test_sqs_client.receive_message( 188 | QueueUrl=self.test_queue_url, MessageAttributeNames=["All"] 189 | ).get("Messages") 190 | self.assertTrue(self.has_msg_attributes(messages, modified_msg_attr_to_check)) 191 | self.assertTrue(self.has_msg_body(messages, modified_msg)) 192 | 193 | def test_platform_endpoint_publish_calls_make_payload(self): 194 | """Test SNSExtendedSession PlatformEndpoint resource publish API call invokes _make_payload method to change the message and message attributes""" 195 | platform_endpoint_resource = SNSExtendedClientSession().resource("sns").PlatformEndpoint(self.test_topic_arn) 196 | 197 | modified_msg_attr = {"dummy": {"StringValue": "dummy", "DataType": "String"}} 198 | modified_msg = "dummy msg" 199 | modified_msg_attr_to_check = { 200 | "dummy": {"Type": "String", "Value": "dummy"} 201 | } # to be compatible with SQS queue message format 202 | 203 | make_payload_mock = create_autospec( 204 | platform_endpoint_resource._make_payload, 205 | return_value=(modified_msg_attr, modified_msg), 206 | ) 207 | 208 | platform_endpoint_resource._make_payload = make_payload_mock 209 | platform_endpoint_resource.publish(MessageAttributes={}, Message="test") 210 | # verify the call to _make_payload 211 | make_payload_mock.assert_called_once_with({}, "test", None) 212 | 213 | # fetch message from sqs queue and verify the modified msg is published 214 | messages = self.test_sqs_client.receive_message( 215 | QueueUrl=self.test_queue_url, MessageAttributeNames=["All"] 216 | ).get("Messages") 217 | self.assertTrue(self.has_msg_attributes(messages, modified_msg_attr_to_check)) 218 | self.assertTrue(self.has_msg_body(messages, modified_msg)) 219 | 220 | def test_make_payload_small_msg(self): 221 | """Test publish method uses the output from the determine_payload method call small msg""" 222 | sns_extended_client = self.sns_extended_client 223 | 224 | expected_msg_attributes = self.small_message_attribute 225 | expected_msg_body = self.small_message_body 226 | 227 | actual_msg_attr, actual_msg_body = sns_extended_client._make_payload( 228 | self.small_message_attribute, self.small_message_body, None 229 | ) 230 | 231 | self.assertEqual(actual_msg_body, expected_msg_body) 232 | self.assertEqual(actual_msg_attr, expected_msg_attributes) 233 | 234 | def test_make_payload_large_msg(self): 235 | """Test publish method uses the output from the make_payload method call large msg""" 236 | sns_extended_client = self.sns_extended_client 237 | 238 | actual_msg_attr, actual_msg_body = sns_extended_client._make_payload( 239 | self.small_message_attribute, self.large_msg_body, None 240 | ) 241 | 242 | expected_msg_attr = self.make_expected_message_attribute( 243 | self.small_message_attribute, self.large_msg_body, RESERVED_ATTRIBUTE_NAME 244 | ) 245 | 246 | self.assertEqual(expected_msg_attr, actual_msg_attr) 247 | 248 | try: 249 | json_body = loads(actual_msg_body) 250 | except JSONDecodeError: 251 | assert False 252 | self.assertEqual(len(json_body), 2) 253 | self.assertEqual(json_body[0], MESSAGE_POINTER_CLASS) 254 | self.assertEqual(json_body[1].get("s3BucketName"), TestSNSExtendedClient.test_bucket_name) 255 | self.assertTrue(self.is_valid_uuid4(json_body[1].get("s3Key"))) 256 | 257 | self.assertEqual(self.large_msg_body, self.get_msg_from_s3(json_body)) 258 | 259 | def test_make_payload_always_through_s3(self): 260 | """Test small message object is stored in S3 when always_through_s3 is set to true""" 261 | sns_extended_client = self.sns_extended_client 262 | 263 | sns_extended_client.always_through_s3 = True 264 | 265 | actual_msg_attr, actual_msg_body = sns_extended_client._make_payload( 266 | self.small_message_attribute, self.small_message_body, None 267 | ) 268 | 269 | expected_msg_attr = self.make_expected_message_attribute( 270 | self.small_message_attribute, self.small_message_body, RESERVED_ATTRIBUTE_NAME 271 | ) 272 | 273 | self.assertEqual(expected_msg_attr, actual_msg_attr) 274 | 275 | try: 276 | json_body = loads(actual_msg_body) 277 | except JSONDecodeError: 278 | assert False 279 | self.assertEqual(len(json_body), 2) 280 | self.assertEqual(json_body[0], MESSAGE_POINTER_CLASS) 281 | self.assertEqual(json_body[1].get("s3BucketName"), TestSNSExtendedClient.test_bucket_name) 282 | self.assertTrue(self.is_valid_uuid4(json_body[1].get("s3Key"))) 283 | 284 | self.assertEqual(self.small_message_body, self.get_msg_from_s3(json_body)) 285 | 286 | def test_make_payload_reduced_message_size_threshold(self): 287 | """Test reduced message size threshold message published to S3""" 288 | sns_extended_client = self.sns_extended_client 289 | 290 | sns_extended_client.message_size_threshold = 128 291 | small_msg = "x" * (sns_extended_client.message_size_threshold + 1) 292 | 293 | actual_msg_attr, actual_msg_body = sns_extended_client._make_payload({}, small_msg, None) 294 | 295 | expected_msg_attr = self.make_expected_message_attribute( 296 | {}, small_msg, RESERVED_ATTRIBUTE_NAME 297 | ) 298 | 299 | self.assertEqual(expected_msg_attr, actual_msg_attr) 300 | 301 | try: 302 | json_body = loads(actual_msg_body) 303 | except JSONDecodeError: 304 | assert False 305 | self.assertEqual(len(json_body), 2) 306 | self.assertEqual(json_body[0], MESSAGE_POINTER_CLASS) 307 | self.assertEqual(json_body[1].get("s3BucketName"), TestSNSExtendedClient.test_bucket_name) 308 | self.assertTrue(self.is_valid_uuid4(json_body[1].get("s3Key"))) 309 | 310 | self.assertEqual(small_msg, self.get_msg_from_s3(json_body)) 311 | 312 | def test_make_payload_use_legacy_reserved_attribute(self): 313 | """Test extended payload messages use the Legacy Reserved Attribute""" 314 | sns_extended_client = self.sns_extended_client 315 | sns_extended_client.use_legacy_attribute = True 316 | 317 | actual_msg_attr, actual_msg_body = sns_extended_client._make_payload( 318 | self.small_message_attribute, self.large_msg_body, None 319 | ) 320 | 321 | expected_msg_attr = self.make_expected_message_attribute( 322 | self.small_message_attribute, 323 | self.large_msg_body, 324 | LEGACY_RESERVED_ATTRIBUTE_NAME, 325 | ) 326 | 327 | self.assertEqual(loads(actual_msg_body)[0], LEGACY_MESSAGE_POINTER_CLASS) 328 | self.assertEqual(expected_msg_attr, actual_msg_attr) 329 | 330 | def test_make_payload_use_custom_S3_key(self): 331 | """Test to verify custom S3 key is used to store message in S3 bucket""" 332 | sns_extended_client = self.sns_extended_client 333 | 334 | s3_key_message_attribute = { 335 | "S3Key": { 336 | "DataType": "String", 337 | "StringValue": "test_key", 338 | } 339 | } 340 | 341 | actual_msg_attr, actual_msg_body = sns_extended_client._make_payload( 342 | s3_key_message_attribute, self.large_msg_body, None 343 | ) 344 | expected_msg_body = dumps( 345 | [ 346 | MESSAGE_POINTER_CLASS, 347 | { 348 | "s3BucketName": sns_extended_client.large_payload_support, 349 | "s3Key": "test_key", 350 | }, 351 | ] 352 | ) 353 | expected_msg_attr = loads(dumps(s3_key_message_attribute)) 354 | expected_msg_attr[RESERVED_ATTRIBUTE_NAME] = { 355 | "DataType": "Number", 356 | "StringValue": str(len(self.large_msg_body.encode())), 357 | } 358 | 359 | self.assertEqual(expected_msg_attr, actual_msg_attr) 360 | self.assertEqual(expected_msg_body, actual_msg_body) 361 | try: 362 | json_body = loads(actual_msg_body) 363 | except JSONDecodeError: 364 | assert False 365 | 366 | self.assertEqual(self.large_msg_body, self.get_msg_from_s3(json_body)) 367 | 368 | def test_check_message_attributes_too_many_attributes(self): 369 | """Test _check_message_attributes method raises Exception when invoked with many message attributes""" 370 | sns_extended_client = self.sns_extended_client 371 | 372 | many_message_attributes = {} 373 | message_attribute_value_string = '{"DataType": "Number", "StringValue": "100"}' 374 | for i in range(MAX_ALLOWED_ATTRIBUTES + 1): 375 | many_message_attributes[i] = loads(message_attribute_value_string) 376 | 377 | self.assertRaises( 378 | SNSExtendedClientException, 379 | sns_extended_client._check_message_attributes, 380 | many_message_attributes, 381 | ) 382 | 383 | def test_check_message_attributes_size(self): 384 | """Test _check_size_of_message_attributes method raises Exception when invoked with big total size of message_attributes""" 385 | sns_extended_client = self.sns_extended_client 386 | 387 | large_message_attribute = { 388 | "large_attribute": { 389 | "DataType": "String", 390 | "StringValue": self.large_msg_body, 391 | } 392 | } 393 | self.assertRaises( 394 | SNSExtendedClientException, 395 | sns_extended_client._check_size_of_message_attributes, 396 | large_message_attribute, 397 | ) 398 | 399 | def test_is_large_message(self): 400 | """Test _is_large_message method which is used to determine if extended payload is to be used""" 401 | sns_extended_client = self.sns_extended_client 402 | 403 | self.assertTrue( 404 | sns_extended_client._is_large_message({}, self.large_msg_body) 405 | ) # large body --> true 406 | self.assertFalse( 407 | sns_extended_client._is_large_message({}, self.small_message_body) 408 | ) # small body --> true 409 | self.assertTrue( 410 | sns_extended_client._is_large_message( 411 | { 412 | "large_attribute": { 413 | "DataType": "String", 414 | "StringValue": self.large_msg_body, 415 | } 416 | }, 417 | self.small_message_body, 418 | ) 419 | ) # large attribute --> True 420 | 421 | def test_publish_json_msg_structure(self): 422 | """Test publish raises exception before publishing json structured message""" 423 | sns_extended_client = self.sns_extended_client 424 | sns_extended_client.always_through_s3 = True 425 | 426 | self.assertRaises( 427 | SNSExtendedClientException, 428 | sns_extended_client.publish, 429 | TopicArn="", 430 | Message='{"key": "value"}', 431 | MessageStructure="json", 432 | ) 433 | 434 | def test_missing_topic_arn(self): 435 | """Test publish raises Exception when publishing without a topic ARN to publish""" 436 | sns_extended_client = self.sns_extended_client 437 | 438 | self.assertRaises( 439 | SNSExtendedClientException, 440 | sns_extended_client.publish, 441 | Message=self.small_message_body, 442 | MessageAttributes=self.small_message_attribute, 443 | ) 444 | 445 | def has_msg_body(self, messages, expected_msg_body, extended_payload=False): 446 | """Checks for target message_body in the list of messages from SQS queue""" 447 | for message in messages: 448 | try: 449 | published_msg = ( 450 | self.get_msg_from_s3(loads(loads(message.get("Body")).get("Message"))) 451 | if extended_payload 452 | else loads(message.get("Body")).get("Message") 453 | ) 454 | if published_msg == expected_msg_body: 455 | return True 456 | except JSONDecodeError: 457 | # skip over non JSON body if looking extended payload objects 458 | continue 459 | 460 | return False 461 | 462 | def make_expected_message_attribute(self, base_attributes, message, reserved_attribute): 463 | expected_msg_attr = loads(dumps(base_attributes)) 464 | expected_msg_attr[reserved_attribute] = { 465 | "DataType": "Number", 466 | "StringValue": str(len(message.encode())), 467 | } 468 | return expected_msg_attr 469 | 470 | def has_msg_attributes(self, messages, message_attributes_to_compare): 471 | """Checks for target message_attributes in the list of messages from SQS queue""" 472 | for message in messages: 473 | body = loads(message.get("Body")) 474 | if message_attributes_to_compare == body.get("MessageAttributes"): 475 | return True 476 | 477 | return False 478 | 479 | def get_msg_from_s3(self, json_msg): 480 | """Fetches message from S3 object described in the json_msg of extended payload""" 481 | msg = self.s3_resource.Object(json_msg[1].get("s3BucketName"), json_msg[1].get("s3Key")).get()["Body"].read().decode("utf-8") 482 | return msg 483 | 484 | def is_valid_uuid4(self, val): 485 | """Checks for valid uuid's""" 486 | try: 487 | uuid.UUID(str(val), version=4) 488 | return True 489 | except ValueError: 490 | return False 491 | 492 | 493 | if __name__ == "__main__": 494 | unittest.main() 495 | -------------------------------------------------------------------------------- /test_integ/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.INFO) 4 | logger = logging.getLogger("Integration Test Logger") 5 | -------------------------------------------------------------------------------- /test_integ/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awslabs/amazon-sns-python-extended-client-lib/233ff73e590532c4225493a25c024174a59b4aff/test_integ/fixtures/__init__.py -------------------------------------------------------------------------------- /test_integ/fixtures/objects.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import pytest 3 | import uuid 4 | 5 | @pytest.fixture 6 | def default_message_size_threshold(): 7 | return 262144 8 | 9 | @pytest.fixture 10 | def small_message_body(): 11 | return "small message body" 12 | 13 | 14 | @pytest.fixture 15 | def small_message_attribute(small_message_body): 16 | return { 17 | 'Small_Message_Attribute': { 18 | 'StringValue': small_message_body, 19 | 'DataType': 'String' 20 | } 21 | } 22 | 23 | @pytest.fixture 24 | def custom_s3_key_attribute(): 25 | return { 26 | 'S3Key': { 27 | 'StringValue': str(uuid.uuid4()), 28 | 'DataType': 'String' 29 | } 30 | } 31 | 32 | 33 | @pytest.fixture 34 | def large_message_body(small_message_body, default_message_size_threshold): 35 | return "x" * ( default_message_size_threshold + 1 ) 36 | 37 | @pytest.fixture 38 | def large_message_attribute(large_message_body): 39 | return { 40 | 'Large_Message_Attribute': { 41 | 'StringValue': 'Test', 42 | 'DataType': 'String' 43 | } 44 | } -------------------------------------------------------------------------------- /test_integ/fixtures/session.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import pytest 3 | from sns_extended_client.session import SNSExtendedClientSession 4 | 5 | @pytest.fixture() 6 | def region_name() -> str: 7 | region_name = 'us-east-1' 8 | return region_name 9 | 10 | @pytest.fixture() 11 | def session(region_name) -> boto3.Session: 12 | 13 | setattr(boto3.session, "Session", SNSExtendedClientSession) 14 | # Now take care of the reference in the boto3.__init__ module since the object is being imported there too 15 | setattr(boto3, "Session", SNSExtendedClientSession) 16 | 17 | # return boto3.session.Session() 18 | print("This session is fetched") 19 | return boto3.Session(region_name=region_name) -------------------------------------------------------------------------------- /test_integ/fixtures/sns.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from sns_extended_client import SNSExtendedClientSession 3 | import pytest 4 | import random 5 | 6 | @pytest.fixture() 7 | def sns_extended_client(session): 8 | sns_client = session.client("sns",region_name='us-east-1') 9 | sns_client.large_payload_support = f'integration-sns-extended-lib-test-bucket-{random.randint(0, 10000)}' 10 | return sns_client 11 | 12 | @pytest.fixture() 13 | def sqs_client(session): 14 | return session.client("sqs") 15 | 16 | @pytest.fixture() 17 | def queue_name(): 18 | return f"IntegrationTestQueue{random.randint(0,10000)}" 19 | 20 | @pytest.fixture() 21 | def topic_name(): 22 | return f"IntegrationTestTopic{random.randint(0,10000)}" 23 | 24 | @pytest.fixture() 25 | def queue(sqs_client, queue_name): 26 | queue_object = sqs_client.create_queue(QueueName=queue_name) 27 | 28 | yield queue_object 29 | 30 | sqs_client.purge_queue( 31 | QueueUrl=queue_object['QueueUrl'] 32 | ) 33 | 34 | sqs_client.delete_queue( 35 | QueueUrl=queue_object['QueueUrl'] 36 | ) 37 | 38 | @pytest.fixture() 39 | def topic(sns_extended_client, topic_name): 40 | topic_arn = sns_extended_client.create_topic(Name=topic_name).get("TopicArn") 41 | 42 | yield topic_arn 43 | 44 | sns_extended_client.delete_topic( 45 | TopicArn=topic_arn 46 | ) 47 | 48 | @pytest.fixture() 49 | def sns_extended_client_with_s3(sns_extended_client): 50 | 51 | client_sns = sns_extended_client 52 | 53 | client_sns.s3_client.create_bucket( 54 | Bucket=client_sns.large_payload_support 55 | ) 56 | 57 | yield client_sns 58 | 59 | client_sns.s3_client.delete_bucket( 60 | Bucket=client_sns.large_payload_support, 61 | ) 62 | -------------------------------------------------------------------------------- /test_integ/test_session.py: -------------------------------------------------------------------------------- 1 | from sns_extended_client import SNSExtendedClientSession 2 | from botocore.exceptions import ClientError 3 | from .fixtures.session import * 4 | from .fixtures.sns import * 5 | from .fixtures.objects import * 6 | from . import logger 7 | from json import loads 8 | import copy 9 | import logging 10 | 11 | 12 | def initialize_extended_client_attributes_through_s3(sns_extended_client): 13 | """ 14 | 15 | Acts as a helper for adding attributes to the extended client 16 | which are required for sending in payloads to S3 buckets. 17 | 18 | sns_extended_client: The SNS Extended Client 19 | 20 | """ 21 | 22 | sns_extended_client.always_through_s3 = True 23 | 24 | return 25 | 26 | def create_allow_sns_to_write_to_sqs_policy_json(topicarn, queuearn): 27 | """ 28 | Creates a policy document which allows SNS to write to SQS 29 | 30 | topicarn: The ARN of the SNS topic 31 | queuearn: The ARN of the SQS queue 32 | 33 | """ 34 | 35 | logger.info("Creating policy document to allow SNS to write to SQS") 36 | policy_document = """{{ 37 | "Version":"2012-10-17", 38 | "Statement":[ 39 | {{ 40 | "Sid":"MyPolicy", 41 | "Effect":"Allow", 42 | "Principal" : {{"AWS" : "*"}}, 43 | "Action":"SQS:SendMessage", 44 | "Resource": "{}", 45 | "Condition":{{ 46 | "ArnEquals":{{ 47 | "aws:SourceArn": "{}" 48 | }} 49 | }} 50 | }} 51 | ] 52 | }}""".format(queuearn, topicarn) 53 | 54 | return policy_document 55 | 56 | def publish_message_helper(sns_extended_client, topic_arn, message_body, message_attributes = None,message_group_id = None, message_deduplication_id = None, **kwargs): 57 | """ 58 | 59 | Acts as a helper for publishing a message via the SNS Extended Client. 60 | 61 | sns_extended_client: The SNS Extended Client 62 | topic_arn: The ARN associated with the SNS Topic 63 | message_body: The message body 64 | message_attributes: The message attributes 65 | 66 | """ 67 | 68 | publish_message_kwargs = { 69 | 'TopicArn': topic_arn, 70 | 'Message': message_body 71 | } 72 | 73 | if message_attributes: 74 | publish_message_kwargs['MessageAttributes'] = message_attributes 75 | 76 | if message_group_id: 77 | publish_message_kwargs['MessageGroupId'] = message_group_id 78 | publish_message_kwargs['MessageDeduplicationId'] = message_deduplication_id 79 | 80 | logger.info("Publishing the message via the SNS Extended Client") 81 | 82 | response = sns_extended_client.publish(**publish_message_kwargs) 83 | 84 | assert response['ResponseMetadata']['HTTPStatusCode'] == 200 85 | 86 | return 87 | 88 | def is_s3_bucket_empty(sns_extended_client): 89 | """ 90 | 91 | Responsible for checking if the S3 bucket created consists 92 | of objects at the time of calling the function. 93 | 94 | sns_extended_client: The SNS Extended Client 95 | 96 | """ 97 | response = sns_extended_client.s3_client.list_objects_v2( 98 | Bucket=sns_extended_client.large_payload_support 99 | ) 100 | 101 | assert response['ResponseMetadata']['HTTPStatusCode'] == 200 102 | return "Contents" not in response 103 | 104 | def retrive_message_from_s3(sns_extended_client,s3Key): 105 | """ 106 | 107 | Responsible for retrieving a message from the S3 bucket. 108 | 109 | sns_extended_client: The SNS Extended Client 110 | s3Key: The S3 Key 111 | 112 | """ 113 | logger.info("Retrieving the message from the S3 bucket") 114 | 115 | target_s3_msg_obj = sns_extended_client.s3_client.get_object(Bucket=sns_extended_client.large_payload_support, Key=s3Key) 116 | 117 | return target_s3_msg_obj['Body'].read().decode() 118 | 119 | def s3_bucket_exist(sns_extended_client): 120 | """ 121 | 122 | Responsible for checking if the S3 bucket created exists 123 | at the time of calling the function. 124 | 125 | sns_extended_client: The SNS Extended Client 126 | 127 | """ 128 | logger.info("Checking if the S3 bucket exists") 129 | 130 | try: 131 | sns_extended_client.s3_client.head_bucket(Bucket=sns_extended_client.large_payload_support) 132 | return True 133 | except ClientError as e: 134 | if e.response['Error']['Code'] == '404': 135 | return False 136 | raise 137 | 138 | def receive_message_helper(sqs_client, queue_url): 139 | """ 140 | 141 | Acts as a helper for receiving a message via the SQS Client. 142 | 143 | sqs_client: The SQS Client 144 | queue_url: The URL associated with the SQS Queue 145 | 146 | """ 147 | 148 | logger.info("Receiving the message via the SQS Client") 149 | 150 | response = sqs_client.receive_message( 151 | QueueUrl=queue_url, 152 | MaxNumberOfMessages=1, 153 | WaitTimeSeconds=5 154 | ) 155 | 156 | assert 'Messages' in response.keys() 157 | 158 | return response 159 | 160 | def extract_message_body_from_response(sns_extended_client,receive_message_response): 161 | """ 162 | 163 | Responsible for extracting the message body from the response received via the SQS Client. 164 | 165 | receive_message_response: The response received from the SQS Client 166 | 167 | """ 168 | 169 | receive_message_response = loads(receive_message_response) 170 | target_s3_msg_obj = sns_extended_client.s3_client.get_object(Bucket=receive_message_response[1].get("s3BucketName"), Key=receive_message_response[1].get("s3Key")) 171 | 172 | return target_s3_msg_obj['Body'].read().decode() 173 | 174 | def check_receive_message_response(sns_extended_client,receive_message_response, message_body, message_attributes): 175 | """ 176 | 177 | Responsible for checking the message received via the SQS Client. 178 | 179 | receive_message_response: The response received from the SQS Client 180 | message_body: The message body 181 | message_attributes: The message attributes 182 | 183 | """ 184 | response_msg_body = extract_message_body_from_response(sns_extended_client,receive_message_response['Messages'][0]['Body']) 185 | assert response_msg_body == message_body 186 | 187 | def delete_message_helper(sqs_client,queue_url, receipet_handle): 188 | """ 189 | 190 | Acts as a helper for deleting a message via the SQS Client. 191 | 192 | sns_extended_client: The SNS Extended Client 193 | queue_url: The URL associated with the SQS Queue 194 | receipet_handle: The receipt handle associated with the message 195 | 196 | """ 197 | 198 | logger.info("Deleting the message via the SQS Client") 199 | 200 | response = sqs_client.delete_message( 201 | QueueUrl=queue_url, 202 | ReceiptHandle=receipet_handle 203 | ) 204 | 205 | print("Response of delete msg : ") 206 | 207 | assert response['ResponseMetadata']['HTTPStatusCode'] == 200 208 | 209 | def delete_object_from_s3_helper(sns_extended_client, s3Key): 210 | """ 211 | 212 | Acts as a helper for deleting an object from the S3 bucket. 213 | 214 | sns_extended_client: The SNS Extended Client 215 | receive_message_response: The receive message from SQS client 216 | 217 | """ 218 | 219 | logger.info("Deleting the object from the S3 bucket") 220 | 221 | response = sns_extended_client.s3_client.delete_object( 222 | Bucket=sns_extended_client.large_payload_support, 223 | Key=s3Key 224 | ) 225 | 226 | assert response['ResponseMetadata']['HTTPStatusCode'] == 204 227 | 228 | def test_publish_receive_small_msg_through_s3(sns_extended_client_with_s3,sqs_client,queue,topic,small_message_body): 229 | """ 230 | Responsible for replicating a workflow where SQS queue subscribe to sns_extended_client's topic. 231 | sns_extended_client publish a message to that topic with the attribute 'always_through_s3' set to true 232 | which will store in S3 and reference of that object is received by SQS queue by calling the helper functions. 233 | 234 | sns_extedned_client_with_s3 : The SNS Extended Client with S3 Bucket 235 | sqs_client: The SQS Client 236 | queue: The SQS Queue 237 | topic: The SNS Topic 238 | small_message_body: The Message 239 | """ 240 | 241 | logger.info("Initializing execution of test_publish_receive_small_msg_through_s3") 242 | 243 | initialize_extended_client_attributes_through_s3(sns_extended_client_with_s3) 244 | 245 | queue_url = queue["QueueUrl"] 246 | sqs_queue_arn = sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])["Attributes"].get("QueueArn") 247 | 248 | kwargs = { 249 | 'sns_extended_client': sns_extended_client_with_s3, 250 | 'topic_arn': topic, 251 | 'queue_url': queue_url, 252 | 'message_body': small_message_body, 253 | } 254 | 255 | # Adding policy to SQS queue such that SNS topic can publish msg to SQS queue 256 | policy_json = create_allow_sns_to_write_to_sqs_policy_json(topic, sqs_queue_arn) 257 | response = sqs_client.set_queue_attributes( 258 | QueueUrl = queue_url, 259 | Attributes = { 260 | 'Policy' : policy_json 261 | } 262 | ) 263 | 264 | # Subscribe SQS queue to SNS topic 265 | sns_extended_client_with_s3.subscribe(TopicArn=topic,Protocol="sqs",Endpoint=sqs_queue_arn,Attributes={"RawMessageDelivery":"true"}) 266 | 267 | publish_message_helper(**kwargs) 268 | 269 | # Message should store into S3 bucket after publishing to the SNS topic 270 | assert not is_s3_bucket_empty(sns_extended_client_with_s3) 271 | 272 | receive_message_response = receive_message_helper(sqs_client,queue_url) 273 | 274 | # Check the message format - The stored Message in S3 has {"s3BucketName": "", "s3Key": "Key Value"} 275 | json_receive_message_body = loads(receive_message_response['Messages'][0]['Body'])[1] 276 | message_stored_in_s3_attributes = ['s3BucketName','s3Key'] 277 | for key in json_receive_message_body.keys(): 278 | assert key in message_stored_in_s3_attributes 279 | 280 | # Retrieve the message from s3 Bucket and check value 281 | assert retrive_message_from_s3(sns_extended_client_with_s3,json_receive_message_body['s3Key']) == small_message_body 282 | 283 | # Delete message from SQS queue 284 | receipet_handle = receive_message_response['Messages'][0]['ReceiptHandle'] 285 | delete_message_helper(sqs_client, queue_url, receipet_handle) 286 | 287 | # Delete message from S3 bucket 288 | delete_object_from_s3_helper(sns_extended_client_with_s3, json_receive_message_body['s3Key']) 289 | 290 | # The S3 bucket should be empty 291 | assert is_s3_bucket_empty(sns_extended_client_with_s3) 292 | logger.info("Completed execution of test_publish_receive_small_msg_through_s3") 293 | 294 | return 295 | 296 | def test_publish_receive_small_msg_not_through_s3(sns_extended_client, sqs_client, queue, topic, small_message_body): 297 | """ 298 | Responsible for replicating a workflow where SQS queue subscribe to sns_extended_client's topic. 299 | sns_extended_client publish a message to that topic with the attribute 'always_through_s3' set to false 300 | which will store in S3 and reference of that object is received by SQS queue by calling the helper functions. 301 | 302 | sns_extended_client: The SNS Extended Client 303 | sqs_client: The SQS Client 304 | queue: The SQS Queue 305 | topic: The SNS Topic 306 | small_message_body: The Message 307 | """ 308 | 309 | logger.info("Initializing execution of test_publish_receive_small_msg_not_through_s3") 310 | 311 | queue_url = queue["QueueUrl"] 312 | sqs_queue_arn = sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])["Attributes"].get("QueueArn") 313 | 314 | kwargs = { 315 | 'sns_extended_client': sns_extended_client, 316 | 'topic_arn': topic, 317 | 'queue_url': queue_url, 318 | 'message_body': small_message_body, 319 | } 320 | 321 | # Adding policy to SQS queue such that SNS topic can publish msg to SQS queue 322 | policy_json = create_allow_sns_to_write_to_sqs_policy_json(topic, sqs_queue_arn) 323 | response = sqs_client.set_queue_attributes( 324 | QueueUrl = queue_url, 325 | Attributes = { 326 | 'Policy' : policy_json 327 | } 328 | ) 329 | 330 | # Subscribe SQS queue to SNS topic 331 | sns_extended_client.subscribe(TopicArn=topic,Protocol="sqs",Endpoint=sqs_queue_arn,Attributes={"RawMessageDelivery":"true"}) 332 | 333 | publish_message_helper(**kwargs) 334 | 335 | receive_message_response = receive_message_helper(sqs_client,queue_url) 336 | 337 | # The body of response should have same message body that was being sent to topic by SNS 338 | assert receive_message_response['Messages'][0]['Body'] == small_message_body 339 | 340 | # Delete message from SQS queue 341 | receipet_handle = receive_message_response['Messages'][0]['ReceiptHandle'] 342 | delete_message_helper(sqs_client, queue_url, receipet_handle) 343 | 344 | logger.info("Completed execution of test_publish_receive_small_msg_not_through_s3") 345 | 346 | return 347 | 348 | def test_publish_receive_large_msg_which_passes_threshold_through_s3(sns_extended_client_with_s3,sqs_client,queue,topic,large_message_body): 349 | """ 350 | Responsible for replicating a workflow where SQS queue subscribe to sns_extended_client's topic. 351 | sns_extended_client publish a message to that topic which exceeds the default threshold 352 | which will store in S3 and reference of that object is received by SQS queue by calling the helper functions. 353 | 354 | sns_extedned_client_with_s3 : The SNS Extended Client with S3 Bucket 355 | sqs_client: The SQS Client 356 | queue: The SQS Queue 357 | topic: The SNS Topic 358 | large_message_body: The Message 359 | """ 360 | 361 | logger.info("Initializing execution of test_publish_receive_large_msg_which_passes_threshold_through_s3") 362 | 363 | queue_url = queue["QueueUrl"] 364 | sqs_queue_arn = sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])["Attributes"].get("QueueArn") 365 | 366 | kwargs = { 367 | 'sns_extended_client': sns_extended_client_with_s3, 368 | 'topic_arn': topic, 369 | 'queue_url': queue_url, 370 | 'message_body': large_message_body, 371 | } 372 | 373 | # Adding policy to SQS queue such that SNS topic can publish msg to SQS queue 374 | policy_json = create_allow_sns_to_write_to_sqs_policy_json(topic, sqs_queue_arn) 375 | response = sqs_client.set_queue_attributes( 376 | QueueUrl = queue_url, 377 | Attributes = { 378 | 'Policy' : policy_json 379 | } 380 | ) 381 | 382 | # Subscribe SQS queue to SNS topic 383 | sns_extended_client_with_s3.subscribe(TopicArn=topic,Protocol="sqs",Endpoint=sqs_queue_arn,Attributes={"RawMessageDelivery":"true"}) 384 | 385 | publish_message_helper(**kwargs) 386 | 387 | # Message should store into S3 bucket after publishing to the SNS topic 388 | assert not is_s3_bucket_empty(sns_extended_client_with_s3) 389 | 390 | receive_message_response = receive_message_helper(sqs_client,queue_url) 391 | 392 | # Check the message format - The stored Message in S3 has {"s3BucketName": "", "s3Key": "Key Value"} 393 | json_receive_message_body = loads(receive_message_response['Messages'][0]['Body'])[1] 394 | message_stored_in_s3_attributes = ['s3BucketName','s3Key'] 395 | for key in json_receive_message_body.keys(): 396 | assert key in message_stored_in_s3_attributes 397 | 398 | # Retrieve the message from s3 Bucket and check value 399 | assert retrive_message_from_s3(sns_extended_client_with_s3,json_receive_message_body['s3Key']) == large_message_body 400 | 401 | # Delete message from SQS queue 402 | receipet_handle = receive_message_response['Messages'][0]['ReceiptHandle'] 403 | delete_message_helper(sqs_client, queue_url, receipet_handle) 404 | 405 | # Delete message from S3 bucket 406 | delete_object_from_s3_helper(sns_extended_client_with_s3, json_receive_message_body['s3Key']) 407 | 408 | # The S3 bucket should be empty 409 | assert is_s3_bucket_empty(sns_extended_client_with_s3) 410 | logger.info("Completed execution of test_publish_receive_large_msg_which_passes_threshold_through_s3") 411 | 412 | return 413 | 414 | def test_publish_receive_msg_with_custom_s3_key(sns_extended_client_with_s3, sqs_client, queue, topic, small_message_body,custom_s3_key_attribute): 415 | """ 416 | Responsible for replicating a workflow where SQS queue subscribe to sns_extended_client's topic. 417 | sns_extended_client publish a message to that topic with the custom attribute to store message in s3 418 | and reference of that object is received by SQS queue by calling the helper functions. 419 | 420 | sns_extended_client_with_s3: The SNS Extended Client with Existed S3 bucket 421 | sqs_client: The SQS Client 422 | queue: The SQS Queue 423 | topic: The SNS Topic 424 | small_message_body: The Message 425 | custom_s3_key_attribute: Attribute to set custom Key of message 426 | """ 427 | 428 | logger.info("Initializing execution of test_publish_receive_small_msg_through_s3") 429 | 430 | initialize_extended_client_attributes_through_s3(sns_extended_client_with_s3) 431 | 432 | queue_url = queue["QueueUrl"] 433 | sqs_queue_arn = sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])["Attributes"].get("QueueArn") 434 | 435 | kwargs = { 436 | 'sns_extended_client': sns_extended_client_with_s3, 437 | 'topic_arn': topic, 438 | 'queue_url': queue_url, 439 | 'message_body': small_message_body, 440 | 'message_attributes': custom_s3_key_attribute 441 | } 442 | 443 | # Adding policy to SQS queue such that SNS topic can publish msg to SQS queue 444 | policy_json = create_allow_sns_to_write_to_sqs_policy_json(topic, sqs_queue_arn) 445 | response = sqs_client.set_queue_attributes( 446 | QueueUrl = queue_url, 447 | Attributes = { 448 | 'Policy' : policy_json 449 | } 450 | ) 451 | 452 | # Subscribe SQS queue to SNS topic 453 | sns_extended_client_with_s3.subscribe(TopicArn=topic,Protocol="sqs",Endpoint=sqs_queue_arn,Attributes={"RawMessageDelivery":"true"}) 454 | 455 | publish_message_helper(**kwargs) 456 | 457 | # Message should store into S3 bucket after publishing to the SNS topic 458 | assert not is_s3_bucket_empty(sns_extended_client_with_s3) 459 | 460 | receive_message_response = receive_message_helper(sqs_client,queue_url) 461 | 462 | # Check the message format - The stored Message in S3 has {"s3BucketName": "", "s3Key": "Key Value"} 463 | json_receive_message_body = loads(receive_message_response['Messages'][0]['Body'])[1] 464 | message_stored_in_s3_attributes = ['s3BucketName','s3Key'] 465 | for key in json_receive_message_body.keys(): 466 | assert key in message_stored_in_s3_attributes 467 | 468 | # Stored message key should be same as custom_s3_key_attribute datavalue 469 | assert custom_s3_key_attribute['S3Key']['StringValue'] == json_receive_message_body['s3Key'] 470 | 471 | # Retrieve the message from s3 Bucket and check value 472 | assert retrive_message_from_s3(sns_extended_client_with_s3,json_receive_message_body['s3Key']) == small_message_body 473 | 474 | # Delete message from SQS queue 475 | receipet_handle = receive_message_response['Messages'][0]['ReceiptHandle'] 476 | delete_message_helper(sqs_client, queue_url, receipet_handle) 477 | 478 | # Delete message from S3 bucket 479 | delete_object_from_s3_helper(sns_extended_client_with_s3, json_receive_message_body['s3Key']) 480 | 481 | # The S3 bucket should be empty 482 | assert is_s3_bucket_empty(sns_extended_client_with_s3) 483 | logger.info("Completed execution of test_publish_receive_small_msg_through_s3") 484 | 485 | return 486 | 487 | def test_session(session): 488 | assert boto3.session.Session == SNSExtendedClientSession 489 | 490 | 491 | --------------------------------------------------------------------------------